Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import './bootstrap';
|
||||
|
||||
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: '',
|
||||
results: [],
|
||||
open: false,
|
||||
loading: false,
|
||||
active: 0,
|
||||
_abort: null,
|
||||
searchUrl: config.searchUrl || '/search',
|
||||
|
||||
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.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
@@ -0,0 +1,33 @@
|
||||
@props(['title' => 'Ladill Hosting'])
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Ladill Hosting' }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@include('partials.favicon')
|
||||
<title>{{ $title ?? 'Hosting Panel' }} - Ladill</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
@php
|
||||
$canViewAccountDetails = auth()->check() && auth()->user()->can('viewAccount', $account);
|
||||
$exitUrl = $canViewAccountDetails
|
||||
? route('hosting.accounts.show', $account)
|
||||
: route('hosting.single-domain');
|
||||
@endphp
|
||||
<body class="bg-slate-100 font-sans antialiased" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen flex">
|
||||
{{-- Mobile sidebar overlay --}}
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<aside class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0 bg-white border-r border-slate-200"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('hosting.panel.partials.sidebar')
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<div class="flex-1 flex flex-col min-w-0 lg:pl-64">
|
||||
{{-- Top bar --}}
|
||||
<header class="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-slate-200 bg-white/95 backdrop-blur px-4 lg:px-6">
|
||||
<button type="button" @click="sidebarOpen = true" class="lg:hidden -ml-2 p-2 text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-sm font-semibold text-slate-800 truncate">{{ $header ?? 'Hosting Panel' }}</h1>
|
||||
@unless ($canViewAccountDetails)
|
||||
<p class="text-xs text-slate-500">Developer access</p>
|
||||
@endunless
|
||||
</div>
|
||||
<a href="{{ $exitUrl }}" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Exit Panel
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{{-- Flash messages --}}
|
||||
@include('partials.flash')
|
||||
|
||||
{{-- Expired Account Banner --}}
|
||||
@if ($account->isExpired() && $account->isInGracePeriod())
|
||||
<div class="mx-4 mt-4 lg:mx-6 lg:mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="h-4.5 w-4.5 shrink-0 text-red-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
|
||||
<p class="text-sm text-red-800"><span class="font-semibold">Account expired.</span> Your site is offline. Files are preserved until {{ $account->gracePeriodEndsAt()->format('M d, Y') }}.
|
||||
<a href="{{ route('hosting.accounts.show', $account) }}" class="underline font-medium hover:text-red-900">Renew now</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Page content --}}
|
||||
<main class="flex-1 p-4 lg:p-6">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
{!! \App\Support\DomainGlobeIcon::svg($class) !!}
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m21 0h-7c-1.657 0-3 1.343-3 3v3h13v-3c0-1.657-1.343-3-3-3zm-6.5 4.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm6.5-.5h-2c-.552 0-1-.448-1-1s.448-1 1-1h2c.552 0 1 .448 1 1s-.448 1-1 1zm-10 6c0 1.657 1.343 3 3 3h7c1.657 0 3-1.343 3-3v-3h-13zm8-1h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm-4.5-.5c.828 0 1.5.672 1.5 1.5s-.672 1.5-1.5 1.5-1.5-.672-1.5-1.5.672-1.5 1.5-1.5zm-5.5-6.5c0 .552-.447 1-1 1h-1c-1.103 0-2 .897-2 2v2.029l1.307-1.25c.399-.383 1.031-.37 1.414.028s.37 1.031-.027 1.414l-2.301 2.212c-.38.378-.883.569-1.388.569-.51 0-1.021-.193-1.41-.582l-2.288-2.199c-.397-.383-.41-1.016-.027-1.414s1.015-.411 1.414-.028l1.307 1.256v-2.035c-.001-2.206 1.793-4 3.999-4h1c.553 0 1 .448 1 1zm12 14v1c0 2.206-1.794 4-4 4h-2.029l1.25 1.307c.383.398.37 1.031-.027 1.414-.398.382-1.031.371-1.414-.028l-2.212-2.301c-.761-.761-.761-2.023.013-2.798l2.199-2.287c.382-.398 1.015-.412 1.414-.028.397.383.41 1.016.027 1.414l-1.257 1.307h2.036c1.103 0 2-.897 2-2v-1c0-.552.447-1 1-1s1 .448 1 1zm-12-1.5v2.421c-.221.268-1.456 1.079-4.5 1.079s-4.279-.811-4.5-1.079v-2.421c0-.883 1.85-1.5 4.5-1.5s4.5.617 4.5 1.5zm-4.5 5.5c1.93 0 3.421-.311 4.5-.777v2.61c0 1.377-1.641 2.167-4.5 2.167s-4.5-.79-4.5-2.167v-2.61c1.079.466 2.57.777 4.5.777z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['class' => 'h-5 w-5'])
|
||||
|
||||
<svg {{ $attributes->merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="m4,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm14-2h-7v7h7v-7Zm-2,5h-3v-3h3v3Zm-14,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm-3,7h4v2H3c-1.654,0-3-1.346-3-3v-4h2v4c0,.551.449,1,1,1Zm19-5h2v4c0,1.654-1.346,3-3,3h-4v-2h4c.551,0,1-.449,1-1v-4Zm2-14v4h-2V3c0-.551-.449-1-1-1h-4V0h4c1.654,0,3,1.346,3,3ZM2,7H0V3C0,1.346,1.346,0,3,0h4v2H3c-.551,0-1,.449-1,1v4Zm11,10h3v3h-3v-3Zm4-1v-3h3v3h-3Zm-4-3h3v3h-3v-3Z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
$strokeWidth = $strokeWidth ?? '1.5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="none" stroke="currentColor" stroke-width="{{ $strokeWidth }}" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,6 @@
|
||||
@php
|
||||
$class = $class ?? 'h-5 w-5';
|
||||
@endphp
|
||||
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m24 12c0 6.617-5.383 12-12 12-3.476 0-6.744-1.542-9-4.104v2.104c0 .552-.447 1-1 1s-1-.448-1-1v-4c0-1.103.897-2 2-2h4c.553 0 1 .448 1 1s-.447 1-1 1h-2.991c1.877 2.49 4.837 4 7.991 4 5.514 0 10-4.486 10-10 0-.552.447-1 1-1s1 .448 1 1zm-22 0c0-5.514 4.486-10 10-10 3.154 0 6.115 1.51 7.991 4h-2.991c-.553 0-1 .448-1 1s.447 1 1 1h4c1.103 0 2-.897 2-2v-4c0-.552-.447-1-1-1s-1 .448-1 1v2.104c-2.256-2.562-5.524-4.104-9-4.104-6.617 0-12 5.383-12 12 0 .552.447 1 1 1s1-.448 1-1zm13.5-3.5c1.93 0 3.5 1.57 3.5 3.5s-1.57 3.5-3.5 3.5c-1.386 0-2.685-1.085-3.5-1.949-.815.864-2.114 1.949-3.5 1.949-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5c1.386 0 2.685 1.085 3.5 1.949.815-.864 2.114-1.949 3.5-1.949zm-4.795 3.5c-.67-.736-1.591-1.5-2.205-1.5-.827 0-1.5.673-1.5 1.5s.673 1.5 1.5 1.5c.614 0 1.535-.764 2.205-1.5zm4.795-1.5c-.614 0-1.535.764-2.205 1.5.67.736 1.591 1.5 2.205 1.5.827 0 1.5-.673 1.5-1.5s-.673-1.5-1.5-1.5z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,33 @@
|
||||
@props(['title' => 'Ladill Hosting'])
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Ladill Hosting' }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Billing — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds mailboxes across every Ladill app.</p>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-5 text-white shadow-sm sm:col-span-1">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Wallet balance</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-3 inline-block rounded-lg bg-white/15 px-3.5 py-1.5 text-xs font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Mailbox subscriptions</h2>
|
||||
<span class="text-xs text-slate-400">priced by storage plan</span>
|
||||
</div>
|
||||
@if(empty($paidMailboxes))
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No paid mailboxes yet — your free allowance covers you.</p>
|
||||
@else
|
||||
<ul class="divide-y divide-slate-50">
|
||||
@foreach($paidMailboxes as $m)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $m['address'] ?? '—' }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $m['quota_label'] }} · {{ ucfirst($m['status'] ?? 'active') }}</p>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700">{{ $fmt($m['price_minor']) }}<span class="text-xs text-slate-400">/mo</span></span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="flex items-center justify-between border-t border-slate-100 px-5 py-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-slate-400">Monthly total</span>
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $fmt($monthlyTotalMinor) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-400">Mailboxes beyond your free allowance renew monthly from your wallet. Keep it funded to avoid interruption.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,70 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Developers — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your mailboxes programmatically.</p>
|
||||
|
||||
@if($newToken)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<p class="text-sm font-semibold text-emerald-900">Your new token — copy it now</p>
|
||||
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
|
||||
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied ✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Create --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
|
||||
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. CI server"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Generate token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Tokens --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
|
||||
@forelse($tokens as $token)
|
||||
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
Created {{ $token->created_at->diffForHumans() }} ·
|
||||
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('account.developers.destroy', $token->id) }}" onsubmit="return confirm('Revoke {{ $token->name }}?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Docs --}}
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
|
||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/mailboxes \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>, <span class="font-mono text-slate-300">GET /mailboxes</span>. More coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,101 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Settings — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Defaults, preferences, and account mailbox linking.</p>
|
||||
|
||||
@if($showMailboxLinkUi ?? (($linkStatus['linked_mailbox'] ?? null) || ($linkStatus['show_reminder'] ?? false)))
|
||||
<div class="mt-6 rounded-2xl border border-indigo-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
|
||||
@include('partials.ladill-pro-icon')
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Link to mailbox</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-slate-500">
|
||||
Your Ladill account uses <strong class="font-medium text-slate-700">{{ $linkStatus['account_email'] ?? $account->email }}</strong>.
|
||||
Link a mailbox so <strong class="font-medium text-slate-700">Ladill Mail</strong> opens with your Ladill sign-in.
|
||||
</p>
|
||||
|
||||
@if($linkStatus['linked_mailbox'] ?? null)
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-800">
|
||||
Linked mailbox: <span class="font-semibold">{{ $linkStatus['linked_mailbox'] }}</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('account.settings.mailbox-unlink') }}" class="mt-3"
|
||||
onsubmit="return confirm('Unlink this mailbox from your Ladill account? Ladill Mail will no longer open automatically with Ladill sign-in.')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50">
|
||||
Unlink mailbox
|
||||
</button>
|
||||
</form>
|
||||
@elseif(($linkStatus['stage'] ?? '') === 'needs_domain')
|
||||
<p class="mt-3 text-sm text-amber-800">Add and verify an email domain first.</p>
|
||||
<a href="{{ route('email.domains.index') }}" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
Go to domains
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
@elseif(($linkStatus['stage'] ?? '') === 'needs_mailbox')
|
||||
<p class="mt-3 text-sm text-amber-800">Create a mailbox on your verified domain first.</p>
|
||||
<a href="{{ route('email.mailboxes.create') }}" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
Create mailbox
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
@elseif(count($mailboxOptions) > 0)
|
||||
<form method="POST" action="{{ route('account.settings.mailbox-link') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf @method('PUT')
|
||||
<div class="min-w-0 flex-1">
|
||||
<label for="mailbox_address" class="block text-[11px] font-medium text-slate-500">Choose mailbox</label>
|
||||
<select id="mailbox_address" name="mailbox_address" required class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<option value="" disabled @selected(! old('mailbox_address'))>Select a mailbox</option>
|
||||
@foreach($mailboxOptions as $mailbox)
|
||||
<option value="{{ $mailbox['address'] }}" @selected(old('mailbox_address') === $mailbox['address'])>{{ $mailbox['address'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('mailbox_address')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">Link mailbox</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Mailbox defaults</h2>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Default mailbox quota</label>
|
||||
<select name="default_quota_mb" class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@php $cur = (int) ($settings->default_quota_mb ?? config('email.default_quota_mb', 10240)); @endphp
|
||||
@foreach([1024 => '1 GB', 5120 => '5 GB', 10240 => '10 GB', 25600 => '25 GB', 51200 => '50 GB'] as $mb => $label)
|
||||
<option value="{{ $mb }}" @selected($cur === $mb)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" name="notify_email" value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<label class="flex items-center justify-between">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Product updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new Ladill Email features.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1" @checked($settings->product_updates ?? true) class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,82 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Team — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this account’s mailboxes & domains.</p>
|
||||
|
||||
@if($canManage)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
|
||||
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Email</label>
|
||||
<input type="email" name="email" required placeholder="teammate@example.com"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Role</label>
|
||||
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Send invite</button>
|
||||
</form>
|
||||
<p class="mt-2 text-[11px] text-slate-400">Admins can manage mailboxes and the team. Members can manage mailboxes. Invitees join by signing in with that email.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
|
||||
<ul class="divide-y divide-slate-50">
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $account->name ?? $account->email }} <span class="text-xs font-normal text-slate-400">(you)</span></p>
|
||||
<p class="text-xs text-slate-400">{{ $account->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
|
||||
</li>
|
||||
|
||||
@forelse($members as $member)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $member->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($member->status === 'invited')
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
|
||||
@endif
|
||||
@if($canManage)
|
||||
<form method="POST" action="{{ route('account.team.role', $member) }}">
|
||||
@csrf @method('PATCH')
|
||||
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
|
||||
<option value="member" @selected($member->role === 'member')>Member</option>
|
||||
<option value="admin" @selected($member->role === 'admin')>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('account.team.destroy', $member) }}" onsubmit="return confirm('Remove {{ $member->email }}?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,27 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Wallet — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds mailboxes across every Ladill app.</p>
|
||||
|
||||
<div class="mt-6 rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-6 text-white shadow-sm">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Balance</p>
|
||||
<p class="mt-1 text-3xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-4 inline-block rounded-lg bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-400">Mailboxes beyond your free allowance are billed monthly from this wallet.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,47 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Overview — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your mailboxes at a glance.</p>
|
||||
</div>
|
||||
<a href="{{ route('email.mailboxes.create') }}" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700">New mailbox</a>
|
||||
</div>
|
||||
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
@foreach([['Mailboxes', $mailboxCount, route('email.mailboxes.index')], ['Domains', $domainCount, route('email.domains.index')], ['Verified domains', $verifiedDomainCount, route('email.domains.index')]] as [$label, $value, $link])
|
||||
<a href="{{ $link }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">{{ $label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $value }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Recent mailboxes</h2>
|
||||
<a href="{{ route('email.mailboxes.index') }}" class="text-xs font-medium text-indigo-600 hover:underline">View all</a>
|
||||
</div>
|
||||
@if(empty($recent))
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Add a domain, verify it, then create your first mailbox.</p>
|
||||
<a href="{{ route('email.domains.index') }}" class="mt-4 inline-block rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Set up a domain</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach($recent as $m)
|
||||
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<span class="text-sm font-medium text-slate-900">{{ $m['address'] }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $m['status'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,30 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Domains — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Email domains</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Add a domain and verify it to create mailboxes on it.</p>
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
<form method="POST" action="{{ route('email.domains.store') }}" class="mt-5 flex gap-2 rounded-2xl border border-slate-200 bg-white p-2">
|
||||
@csrf
|
||||
<input name="domain" required placeholder="yourdomain.com" class="flex-1 rounded-xl border-0 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-0">
|
||||
<button class="rounded-xl bg-indigo-600 px-5 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Add domain</button>
|
||||
</form>
|
||||
|
||||
@if(!empty($domains))
|
||||
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@foreach($domains as $d)
|
||||
<a href="{{ route('email.domains.show', $d['id']) }}" class="flex items-center justify-between px-5 py-4 transition hover:bg-slate-50">
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $d['domain'] }}</span>
|
||||
@if($d['active'] ?? false)
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
|
||||
@else
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending verification</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,62 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', $domain['domain'].' — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.domains.index') }}" class="hover:text-slate-600">Domains</a><span>/</span>
|
||||
<span class="text-slate-600">{{ $domain['domain'] }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $domain['domain'] }}</h1>
|
||||
@if($domain['active'] ?? false)
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
|
||||
@else
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@unless($domain['active'] ?? false)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Publish these DNS records</h2></div>
|
||||
@php $records = $domain['dns_records'] ?? []; @endphp
|
||||
@if(empty($records))
|
||||
<p class="px-5 py-6 text-sm text-slate-400">No DNS records returned. Try refreshing.</p>
|
||||
@else
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-[11px] uppercase tracking-wide text-slate-400">
|
||||
<tr><th class="px-5 py-2 font-medium">Type</th><th class="px-2 py-2 font-medium">Name</th><th class="px-2 py-2 font-medium">Value</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach($records as $r)
|
||||
<tr>
|
||||
<td class="px-5 py-2.5 font-medium text-slate-700">{{ $r['type'] ?? '' }}</td>
|
||||
<td class="px-2 py-2.5 font-mono text-xs text-slate-600">{{ $r['name'] ?? ($r['host'] ?? '@') }}</td>
|
||||
<td class="px-2 py-2.5 max-w-xs truncate font-mono text-xs text-slate-600" title="{{ $r['value'] ?? '' }}">{{ $r['value'] ?? '' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('email.domains.verify', $domain['id']) }}" class="mt-4">
|
||||
@csrf
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">Verify domain</button>
|
||||
<span class="ml-2 text-xs text-slate-400">DNS changes can take time to propagate.</span>
|
||||
</form>
|
||||
@else
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-sm text-slate-600">This domain is verified. <a href="{{ route('email.mailboxes.create') }}" class="font-semibold text-indigo-600 hover:underline">Create a mailbox</a> on it.</p>
|
||||
<dl class="mt-3 flex gap-4 text-xs text-slate-500">
|
||||
<div>SPF <span class="{{ ($domain['spf'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['spf'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
<div>DKIM <span class="{{ ($domain['dkim'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dkim'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
<div>DMARC <span class="{{ ($domain['dmarc'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dmarc'] ?? false) ? '✓' : '✗' }}</span></div>
|
||||
</dl>
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<form method="POST" action="{{ route('email.domains.destroy', $domain['id']) }}" class="mt-4" onsubmit="return confirm('Remove {{ $domain['domain'] }}?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs font-medium text-rose-600 hover:underline">Remove domain</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,95 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'New mailbox — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Create a mailbox</h1>
|
||||
@if(empty($domains))
|
||||
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800">
|
||||
You need a <strong>verified domain</strong> first. <a href="{{ route('email.domains.index') }}" class="font-semibold underline">Set up a domain</a>.
|
||||
</div>
|
||||
@else
|
||||
@php $fmt = fn ($m) => $quota['currency'].' '.number_format($m / 100, 2); @endphp
|
||||
<div x-data="{
|
||||
tiers: @js($quota['tiers']),
|
||||
sel: {{ $quota['default_mb'] }},
|
||||
balance: {{ $walletBalanceMinor }},
|
||||
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
|
||||
get price() { return this.tier.price_minor; },
|
||||
get free() { return this.price === 0; },
|
||||
fmt(m) { return '{{ $quota['currency'] }} ' + (m / 100).toFixed(2); },
|
||||
}">
|
||||
{{-- Pricing banner (reacts to the selected storage plan) --}}
|
||||
<template x-if="!free">
|
||||
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
|
||||
This plan is <strong><span x-text="fmt(price)"></span>/month</strong>, charged from your Ladill wallet
|
||||
(balance: <span x-text="fmt(balance)"></span>).
|
||||
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="free">
|
||||
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
The <strong>1 GB</strong> plan is <strong>free, forever</strong>. You can upgrade for more storage anytime.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form method="POST" action="{{ route('email.mailboxes.store') }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Domain</label>
|
||||
<select name="email_domain_id" class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@foreach($domains as $d)<option value="{{ $d['id'] }}">{{ $d['domain'] }}</option>@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Mailbox name</label>
|
||||
<input name="local_part" required value="{{ old('local_part') }}" placeholder="you"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
@error('local_part')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Display name</label>
|
||||
<input name="display_name" required value="{{ old('display_name') }}" placeholder="Your Name"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
|
||||
{{-- Storage plan (sets quota + price) --}}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Storage plan</label>
|
||||
<div class="mt-1.5 grid grid-cols-1 gap-2">
|
||||
@foreach($quota['tiers'] as $t)
|
||||
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
|
||||
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
|
||||
<span class="flex items-center gap-2.5">
|
||||
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
|
||||
</span>
|
||||
@if($t['price_minor'] === 0)
|
||||
<span class="font-semibold text-emerald-600">Free</span>
|
||||
@else
|
||||
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
|
||||
@endif
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Password</label>
|
||||
<input type="password" name="password" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Confirm</label>
|
||||
<input type="password" name="password_confirmation" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
@error('password')<p class="text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
|
||||
<button class="w-full rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
x-text="free ? 'Create free mailbox' : ('Create mailbox — ' + fmt(price) + '/mo')">Create mailbox</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,32 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Mailboxes — Ladill Email')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Mailboxes</h1>
|
||||
<a href="{{ route('email.mailboxes.create') }}" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">New mailbox</a>
|
||||
</div>
|
||||
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
|
||||
|
||||
@if(empty($mailboxes))
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Verify a domain, then create a mailbox like you@yourdomain.com.</p>
|
||||
<a href="{{ route('email.mailboxes.create') }}" class="mt-4 inline-block rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Create mailbox</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@foreach($mailboxes as $m)
|
||||
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between gap-3 px-5 py-4 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $m['address'] }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $m['display_name'] ?? '' }} · {{ number_format(($m['quota_mb'] ?? 0)/1024, 0) }} GB</p>
|
||||
</div>
|
||||
@php $st = $m['status'] ?? 'pending'; $badge = $st === 'active' ? 'bg-emerald-50 text-emerald-700' : ($st === 'failed' ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-600'); @endphp
|
||||
<span class="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium capitalize {{ $badge }}">{{ $st }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,83 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', $mailbox['address'].' — Ladill Email')
|
||||
@section('content')
|
||||
@php $host = 'mail.'.config('app.platform_domain'); @endphp
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.mailboxes.index') }}" class="hover:text-slate-600">Mailboxes</a><span>/</span>
|
||||
<span class="text-slate-600">{{ $mailbox['address'] }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $mailbox['address'] }}</h1>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $mailbox['status'] ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Connection settings</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Use these in any mail client, or just open webmail.</p>
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Username</dt><dd class="font-mono text-slate-800">{{ $mailbox['address'] }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">IMAP</dt><dd class="font-mono text-slate-800">{{ $host }}:993 (SSL)</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">SMTP</dt><dd class="font-mono text-slate-800">{{ $host }}:587 (STARTTLS)</dd></div>
|
||||
</dl>
|
||||
<a href="https://{{ $host }}" class="mt-4 inline-block rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Open Webmail ↗</a>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$quotaMb = (int) ($mailbox['quota_mb'] ?? 0);
|
||||
$usedBytes = (int) ($mailbox['used_bytes'] ?? 0);
|
||||
$price = \App\Support\MailboxPricing::priceMinorFor($quotaMb);
|
||||
$tiers = \App\Support\MailboxPricing::tiers();
|
||||
$maxMb = collect($tiers)->max('mb');
|
||||
$pct = $quotaMb > 0 ? min(100, (int) round($usedBytes / ($quotaMb * 1048576) * 100)) : 0;
|
||||
$fmt = fn ($m) => config('email.currency', 'GHS').' '.number_format($m / 100, 2);
|
||||
@endphp
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-900">Storage plan</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-600">
|
||||
<span class="font-semibold">{{ \App\Support\MailboxPricing::label($quotaMb) }}</span>
|
||||
@if($price === 0)
|
||||
<span class="ml-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Free</span>
|
||||
@else
|
||||
<span class="ml-1 text-xs text-slate-400">{{ $fmt($price) }}/month</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if($quotaMb < $maxMb)
|
||||
<a href="{{ route('email.mailboxes.upgrade', $mailbox['id']) }}" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Upgrade</a>
|
||||
@else
|
||||
<span class="rounded-lg bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500">Top plan</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($quotaMb > 0)
|
||||
<div class="mt-4">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full {{ $pct >= 90 ? 'bg-rose-500' : 'bg-indigo-500' }}" style="width: {{ max(2, $pct) }}%"></div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-slate-400">{{ number_format($usedBytes / 1048576, 0) }} MB of {{ \App\Support\MailboxPricing::label($quotaMb) }} used ({{ $pct }}%)</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Reset password</h2>
|
||||
<form method="POST" action="{{ route('email.mailboxes.password', $mailbox['id']) }}" class="mt-3 grid grid-cols-2 gap-3">
|
||||
@csrf @method('PATCH')
|
||||
<input type="password" name="password" required placeholder="New password" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<input type="password" name="password_confirmation" required placeholder="Confirm" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<button class="col-span-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-rose-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-rose-700">Delete mailbox</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">This permanently removes the mailbox and its email.</p>
|
||||
<form method="POST" action="{{ route('email.mailboxes.destroy', $mailbox['id']) }}" class="mt-3" onsubmit="return confirm('Delete {{ $mailbox['address'] }}? This cannot be undone.')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Delete mailbox</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,52 @@
|
||||
@extends('layouts.email')
|
||||
@section('title', 'Upgrade storage — Ladill Email')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => $currency.' '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<a href="{{ route('email.mailboxes.show', $mailbox['id']) }}" class="hover:text-slate-600">{{ $mailbox['address'] }}</a><span>/</span>
|
||||
<span class="text-slate-600">Upgrade</span>
|
||||
</div>
|
||||
<h1 class="mt-2 text-xl font-semibold tracking-tight text-slate-900">Upgrade storage</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
Currently on <strong>{{ \App\Support\MailboxPricing::label($currentMb) }}</strong>. Pick a larger plan — billed monthly from your wallet.
|
||||
</p>
|
||||
|
||||
<div x-data="{
|
||||
tiers: @js($tiers),
|
||||
sel: {{ $defaultMb }},
|
||||
balance: {{ $walletBalanceMinor }},
|
||||
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
|
||||
get price() { return this.tier.price_minor; },
|
||||
fmt(m) { return '{{ $currency }} ' + (m / 100).toFixed(2); },
|
||||
}">
|
||||
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
|
||||
New plan: <strong><span x-text="fmt(price)"></span>/month</strong>, charged now from your wallet
|
||||
(balance: <span x-text="fmt(balance)"></span>).
|
||||
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('email.mailboxes.upgrade.store', $mailbox['id']) }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PATCH')
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Choose a plan</label>
|
||||
<div class="mt-1.5 grid grid-cols-1 gap-2">
|
||||
@foreach($tiers as $t)
|
||||
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
|
||||
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
|
||||
<span class="flex items-center gap-2.5">
|
||||
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
|
||||
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
|
||||
</span>
|
||||
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="w-full rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
x-text="'Upgrade — ' + fmt(price) + '/mo'">Upgrade</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signed out — Ladill Email</title>
|
||||
@include('partials.favicon')
|
||||
@vite(['resources/css/app.css'])
|
||||
<meta http-equiv="refresh" content="2;url={{ 'https://'.config('app.platform_domain') }}">
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center bg-slate-100 font-sans">
|
||||
<div class="text-center">
|
||||
<img src="{{ asset('images/logo/ladillemail-logo.svg') }}" alt="Ladill Email" class="mx-auto h-7 w-auto">
|
||||
<p class="mt-6 text-sm text-slate-600">You’ve been signed out. Redirecting…</p>
|
||||
<a href="{{ 'https://'.config('app.platform_domain') }}" class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline">Go to ladill.com</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,694 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Hosting Account Details</x-slot>
|
||||
|
||||
@php
|
||||
$statusColors = [
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
'pending' => 'bg-amber-50 text-amber-700',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700',
|
||||
'failed' => 'bg-red-50 text-red-700',
|
||||
];
|
||||
|
||||
$statusLabels = [
|
||||
'active' => 'Active',
|
||||
'suspended' => 'Suspended',
|
||||
'pending' => 'Pending Setup',
|
||||
'provisioning' => 'Setting Up...',
|
||||
'failed' => 'Setup Failed',
|
||||
];
|
||||
|
||||
$formErrors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$addDomainAction = \Illuminate\Support\Facades\Route::has('hosting.panel.domains.add')
|
||||
? route('hosting.panel.domains.add', $account)
|
||||
: '#';
|
||||
$panelIndexUrl = \Illuminate\Support\Facades\Route::has('hosting.panel.index')
|
||||
? route('hosting.panel.index', $account)
|
||||
: '#';
|
||||
$panelSettingsUrl = \Illuminate\Support\Facades\Route::has('hosting.panel.settings')
|
||||
? route('hosting.panel.settings', $account)
|
||||
: '#';
|
||||
$resourceStatusColors = [
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'throttled' => 'bg-amber-50 text-amber-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all();
|
||||
$oldDomain = trim((string) old('domain', ''));
|
||||
$initialDomainSource = $ownedDomains->isEmpty()
|
||||
? 'external'
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$externalFallback = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
@endphp
|
||||
|
||||
<script>if (window !== window.top) window.top.location.href = window.location.href;</script>
|
||||
<div class="space-y-6" x-data="{
|
||||
domainModal: {{ $formErrors->has('domain') || $formErrors->has('onboarding_mode') || $formErrors->has('document_root') ? 'true' : 'false' }},
|
||||
panelModal: false,
|
||||
renewModal: false,
|
||||
selectedDuration: null,
|
||||
showSheet: false,
|
||||
checkoutUrl: '',
|
||||
renewLoading: false,
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwned),
|
||||
externalDomain: @js($externalFallback),
|
||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}',
|
||||
async submitRenewal() {
|
||||
this.renewLoading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route('hosting.accounts.renew', $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ duration_months: this.selectedDuration }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
|
||||
this.renewModal = false;
|
||||
if (window.innerWidth < 768) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.renewLoading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error. Please try again.');
|
||||
this.renewLoading = false;
|
||||
}
|
||||
}
|
||||
}">
|
||||
{{-- Breadcrumb --}}
|
||||
<nav class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<a href="{{ route('hosting.single-domain') }}" class="hover:text-slate-700 transition">Hosting</a>
|
||||
<svg class="h-3.5 w-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5m0 0v-15"/></svg>
|
||||
<span class="font-medium text-slate-800">{{ $account->primary_domain ?: $account->username }}</span>
|
||||
</nav>
|
||||
|
||||
{{-- Expired Account Banner --}}
|
||||
@if ($account->isExpired() && $account->isInGracePeriod())
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="h-5 w-5 shrink-0 text-red-500 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-900">Hosting Account Expired</h3>
|
||||
<p class="text-sm text-red-700 mt-0.5">Your hosting expired on <span class="font-medium">{{ $account->expires_at->format('M d, Y') }}</span>. Your site is offline but your files are safe. Renew before <span class="font-medium">{{ $account->gracePeriodEndsAt()->format('M d, Y') }}</span> to avoid automatic deletion.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Pending Setup Banner --}}
|
||||
@if ($account->status === 'pending')
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="h-5 w-5 shrink-0 text-amber-500 mt-0.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356m-.984 13.287A9 9 0 1 1 19.07 7.93l1.945 1.418"/></svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-900">Setting Up Your Hosting</h3>
|
||||
<p class="text-sm text-amber-700 mt-0.5">Your hosting account is being set up on our servers. This usually takes 1-2 minutes. Please refresh this page shortly to check the status.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Provisioning Banner --}}
|
||||
@if ($account->status === 'provisioning')
|
||||
<div class="rounded-xl border border-blue-200 bg-blue-50 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="h-5 w-5 shrink-0 text-blue-500 mt-0.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356m-.984 13.287A9 9 0 1 1 19.07 7.93l1.945 1.418"/></svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-blue-900">Provisioning In Progress</h3>
|
||||
<p class="text-sm text-blue-700 mt-0.5">We're creating your hosting environment, setting up PHP, and configuring your server. This typically takes 1-3 minutes. Please refresh this page to check progress.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Failed Setup Banner --}}
|
||||
@if ($account->status === 'failed')
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="h-5 w-5 shrink-0 text-red-500 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-900">Setup Failed</h3>
|
||||
<p class="text-sm text-red-700 mt-0.5">There was a problem setting up your hosting account. Our team has been notified and will resolve this shortly. If you need immediate assistance, please <a href="{{ ladill_account_url('/support-tickets/create') }}" class="underline font-medium hover:text-red-800">open a support ticket</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Hero Card --}}
|
||||
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50/60 via-white to-slate-50/40"></div>
|
||||
<div class="relative flex flex-wrap items-start justify-between gap-4 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-indigo-100 text-indigo-600">
|
||||
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0 0 12 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold tracking-tight text-slate-900">Hosting Account</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2.5">
|
||||
<span class="text-sm text-slate-700">{{ $account->primary_domain ?: $account->username }}</span>
|
||||
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold {{ $statusColors[$account->status ?? 'pending'] }}">
|
||||
{{ $statusLabels[$account->status ?? 'Pending'] }}
|
||||
</span>
|
||||
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold {{ $resourceStatusColors[$account->resource_status ?? 'active'] }}">
|
||||
{{ ucfirst($account->resource_status ?? 'active') }} resources
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-slate-500">Created {{ $account->created_at->format('M d, Y') }}
|
||||
@if ($account->expires_at) · Expires {{ $account->expires_at->format('M d, Y') }} @endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($account->assignedDurationMonths() && !empty($renewalOptions) && ($account->isExpired() || $account->expires_at?->isBefore(now()->addDays(30))))
|
||||
<button @click="renewModal = true; selectedDuration = {{ collect($renewalOptions)->firstWhere('current', true)['months'] ?? $account->assignedDurationMonths() }}" type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356m-.984 13.287A9 9 0 1 1 19.07 7.93l1.945 1.418"/></svg>
|
||||
Renew Plan
|
||||
</button>
|
||||
@endif
|
||||
<button @click="panelModal = true" type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||
Control Panel
|
||||
</button>
|
||||
@if ($canLinkMoreDomains)
|
||||
<button @click="domainModal = true" type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Link Domain
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!empty($upsellRecommendations))
|
||||
<div class="rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-white">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-amber-100 px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Recommended Next Steps</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">These suggestions are based on how this account is being used right now.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-semibold text-amber-700">{{ count($upsellRecommendations) }} suggestion{{ count($upsellRecommendations) === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 px-5 py-4 lg:grid-cols-2">
|
||||
@foreach ($upsellRecommendations as $recommendation)
|
||||
<div class="rounded-xl border border-amber-100 bg-white p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">{{ $recommendation['title'] }}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ $recommendation['message'] }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-700">
|
||||
{{ $recommendation['type'] === 'plan_upgrade' ? 'Upgrade' : 'Add-on' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (($recommendation['type'] ?? null) === 'plan_upgrade' && !empty($recommendation['target_product_id']))
|
||||
<form method="POST" action="{{ route('hosting.accounts.plan-change', $account) }}" class="mt-4">
|
||||
@csrf
|
||||
<input type="hidden" name="target_product_id" value="{{ $recommendation['target_product_id'] }}">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-slate-800">
|
||||
{{ $recommendation['cta_label'] }}
|
||||
</button>
|
||||
</form>
|
||||
@elseif (!empty($recommendation['cta_url']))
|
||||
<div class="mt-4">
|
||||
<a href="{{ $recommendation['cta_url'] }}" class="inline-flex items-center rounded-lg bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-slate-800">
|
||||
{{ $recommendation['cta_label'] }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Info Cards Grid --}}
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-4">
|
||||
{{-- Account Details Card --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-100 px-5 py-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0 0 12 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75Z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Account Details</h3>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Username</span>
|
||||
<span class="text-sm font-medium text-slate-800">{{ $account->username }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Node IP</span>
|
||||
<span class="text-sm font-medium text-slate-800">{{ $account->node?->ip_address ?? '161.97.138.149' }}</span>
|
||||
</div>
|
||||
@if ($account->product)
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Product</span>
|
||||
<span class="text-sm font-medium text-slate-800">{{ $account->product->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Email Usage</span>
|
||||
<span class="text-sm font-medium text-slate-800">
|
||||
{{ $emailUsage['mailbox_count'] }}
|
||||
@if ($emailUsage['free_allowance'] === null)
|
||||
/ Unlimited included
|
||||
@else
|
||||
/ {{ $emailUsage['free_allowance'] }} included
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@if ($emailUsage['extra_count'] > 0)
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
Extra Emails: {{ $emailUsage['extra_count'] }} x GHS 5 = GHS {{ number_format($emailUsage['extra_total'], 2) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Resources Card --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-100 px-5 py-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-50 text-violet-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Resources</h3>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
@php
|
||||
$diskLimit = max(($account->allocated_disk_gb ?: ($account->product?->disk_gb ?? 10)), 1) * 1073741824;
|
||||
$bandwidthLimit = $account->resource_limits['bandwidth_limit_bytes'] ?? 107374182400; // 100GB default
|
||||
$diskUsed = $account->disk_used_bytes ?? 0;
|
||||
$bandwidthUsed = $account->bandwidth_used_bytes ?? 0;
|
||||
$diskPercent = $diskLimit > 0 ? ($diskUsed / $diskLimit) * 100 : 0;
|
||||
$bandwidthPercent = $bandwidthLimit > 0 ? ($bandwidthUsed / $bandwidthLimit) * 100 : 0;
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500">Disk Space</span>
|
||||
<span class="text-xs text-slate-500">{{ number_format($diskUsed / 1073741824, 1) }} GB / {{ number_format($diskLimit / 1073741824, 1) }} GB</span>
|
||||
</div>
|
||||
<div class="w-full rounded-full bg-slate-200 h-1.5">
|
||||
<div class="rounded-full bg-gradient-to-r from-blue-500 to-blue-600 h-1.5" style="width: {{ min($diskPercent, 100) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500">Bandwidth</span>
|
||||
<span class="text-xs text-slate-500">{{ number_format($bandwidthUsed / 1073741824, 1) }} GB / {{ number_format($bandwidthLimit / 1073741824, 1) }} GB</span>
|
||||
</div>
|
||||
<div class="w-full rounded-full bg-slate-200 h-1.5">
|
||||
<div class="rounded-full bg-gradient-to-r from-purple-500 to-purple-600 h-1.5" style="width: {{ min($bandwidthPercent, 100) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@if ($account->php_version)
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">PHP Version</span>
|
||||
<span class="text-xs font-medium text-slate-800">{{ $account->php_version }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">CPU</span>
|
||||
<span class="text-xs font-medium text-slate-800">{{ number_format((float) ($account->cpu_usage_percent ?? 0), 1) }}% / {{ $account->cpu_limit_percent ?? '-' }}%</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Memory</span>
|
||||
<span class="text-xs font-medium text-slate-800">{{ $account->memory_used_mb ?? 0 }} MB / {{ $account->memory_limit_mb ?? '-' }} MB</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Processes</span>
|
||||
<span class="text-xs font-medium text-slate-800">{{ $account->process_count ?? 0 }} / {{ $account->process_limit ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Inodes</span>
|
||||
<span class="text-xs font-medium text-slate-800">{{ number_format((int) ($account->inode_count ?? 0)) }} / {{ number_format((int) ($account->inode_limit ?? 0)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Features Card --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-100 px-5 py-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .582.416l5.135.1a.564.564 0 0 1 .314.988l-3.74 3.247a.563.563 0 0 0-.182.613l1.28 3.982a.562.562 0 0 1-.84.61l-4.652-2.52a.562.562 0 0 0-.6 0L6.68 19.03a.562.562 0 0 1-.84-.61l1.28-3.982a.562.562 0 0 0-.182-.613l-3.74-3.247a.562.562 0 0 1 .314-.988l5.135-.1a.562.562 0 0 0 .582-.416l2.125-5.11Z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Features</h3>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-2">
|
||||
@php
|
||||
$features = $account->features_enabled ?? [];
|
||||
$defaultFeatures = ['SFTP Access', 'FTP Access', 'Email Accounts', 'MySQL Database'];
|
||||
$displayFeatures = !empty($features) ? $features : $defaultFeatures;
|
||||
@endphp
|
||||
@foreach ($displayFeatures as $feature)
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-emerald-500"></div>
|
||||
<span class="text-xs text-slate-700">{{ $feature }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Health Card --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-100 px-5 py-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Health</h3>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Status</span>
|
||||
<span class="text-xs font-semibold {{ $account->status === 'active' ? 'text-emerald-600' : ($account->status === 'suspended' ? 'text-red-600' : 'text-amber-600') }}">
|
||||
{{ ucfirst($account->status ?? 'Unknown') }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($account->provisioned_at)
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Provisioned</span>
|
||||
<span class="text-xs text-slate-600">{{ $account->provisioned_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($account->suspended_at)
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Suspended</span>
|
||||
<span class="text-xs text-slate-600">{{ $account->suspended_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($account->suspension_reason))
|
||||
<div class="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
{{ $account->suspension_reason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($account->uploads_restricted)
|
||||
<div class="text-xs text-amber-700 bg-amber-50 rounded px-2 py-1">
|
||||
Uploads are temporarily restricted because the inode limit has been reached.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Domain Card --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Domains</h3>
|
||||
</div>
|
||||
@if ($canLinkMoreDomains)
|
||||
<button @click="domainModal = true" type="button" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition">+ Add</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
@if ($linkedSites->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
@foreach ($linkedSites as $site)
|
||||
<div class="flex items-start justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50/70 px-4 py-3">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-slate-800">{{ $site->domain }}</span>
|
||||
@if ($site->type === 'primary')
|
||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold text-blue-700">Primary</span>
|
||||
@endif
|
||||
@if ($site->ssl_enabled)
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">SSL</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $site->document_root }}</p>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold {{ $site->status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700' }}">
|
||||
{{ ucfirst($site->status) }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
{{ $linkedSites->count() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
|
||||
</p>
|
||||
@elseif ($account->primary_domain)
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-800">{{ $account->primary_domain }}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-100 text-emerald-700">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">Primary domain for this hosting account.</p>
|
||||
@else
|
||||
<div class="flex flex-col items-center py-4 text-center">
|
||||
<p class="text-sm text-slate-500">No domain linked</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Link a domain to use your hosting account.</p>
|
||||
@if ($canLinkMoreDomains)
|
||||
<button @click="domainModal = true" type="button" class="mt-2.5 text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition">Connect a domain</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Domain Linking Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="domainModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="domainModal = false">
|
||||
<div x-show="domainModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden sm:block" @click="domainModal = false"></div>
|
||||
|
||||
<div x-show="domainModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop>
|
||||
|
||||
<form method="POST" action="{{ $addDomainAction }}">
|
||||
@csrf
|
||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
||||
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-base font-semibold text-slate-900">Connect a Domain</h3>
|
||||
<button @click="domainModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
{{-- Domain Source Toggle --}}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-slate-700">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ladill (Owned) Domains --}}
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
@if($ownedDomains->isNotEmpty())
|
||||
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
|
||||
class="block w-full rounded-xl border-slate-200 bg-slate-50 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
|
||||
<option value="">Choose a domain...</option>
|
||||
@foreach($ownedDomains as $domain)
|
||||
<option value="{{ $domain->host }}">{{ $domain->host }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-slate-400">
|
||||
Don't have a domain?
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
|
||||
</p>
|
||||
@else
|
||||
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
|
||||
No domains found in your account.
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- External Domain --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input name="domain" type="text" x-model="externalDomain" :disabled="domainSource !== 'external'" placeholder="example.com"
|
||||
class="w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-2.5 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
|
||||
<p class="mt-1.5 text-xs text-slate-400">Enter your domain without www or http (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
@if ($formErrors->has('domain'))
|
||||
<p class="mt-2 text-xs text-rose-600">{{ $formErrors->first('domain') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Connection Method (External only) --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Connection method</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
|
||||
:class="onboardingMode === 'ns_auto' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="ns_auto" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">Point Nameservers</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
|
||||
:class="onboardingMode === 'manual_dns' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="manual_dns" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">Add DNS Records</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<button @click="domainModal = false" type="button" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">Cancel</button>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition">
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Renewal Duration Modal --}}
|
||||
@if (!empty($renewalOptions))
|
||||
<template x-teleport="body">
|
||||
<div x-show="renewModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="renewModal = false">
|
||||
<div x-show="renewModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden sm:block" @click="renewModal = false"></div>
|
||||
|
||||
<div x-show="renewModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop>
|
||||
|
||||
<div>
|
||||
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">Renew Hosting Plan</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{{ $account->primary_domain ?: $account->username }}</p>
|
||||
</div>
|
||||
<button @click="renewModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-sm text-slate-600">Choose a renewal duration:</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($renewalOptions as $option)
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-xl border p-4 transition"
|
||||
:class="selectedDuration === {{ $option['months'] }} ? 'border-indigo-300 bg-indigo-50/50 ring-1 ring-indigo-200' : 'border-slate-200 hover:border-slate-300'">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="radio" name="duration_radio" value="{{ $option['months'] }}"
|
||||
x-model.number="selectedDuration"
|
||||
class="text-indigo-600 focus:ring-indigo-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $option['label'] }}</p>
|
||||
@if ($option['current'])
|
||||
<p class="text-[11px] text-indigo-600 font-medium">Current plan term</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-slate-800">{{ $option['currency'] }} {{ number_format($option['amount'], 2) }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($account->expires_at)
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
||||
Current expiry: <span class="font-medium text-slate-700">{{ $account->expires_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-slate-100 px-6 py-4">
|
||||
<button @click="renewModal = false" type="button" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">Cancel</button>
|
||||
<button type="button" @click="submitRenewal()" :disabled="!selectedDuration || renewLoading"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<svg x-show="renewLoading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
<svg x-show="!renewLoading" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
<span x-text="renewLoading ? 'Processing…' : 'Proceed to Payment'">Proceed to Payment</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
|
||||
@include('partials.paystack-sheet')
|
||||
|
||||
{{-- Control Panel Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="panelModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="panelModal = false">
|
||||
<div x-show="panelModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden sm:block" @click="panelModal = false"></div>
|
||||
|
||||
<div x-show="panelModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop>
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-base font-semibold text-slate-900">Control Panel Access</h3>
|
||||
<button @click="panelModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-sm font-medium text-slate-900 mb-4">SFTP Access Details:</p>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">Host:</span>
|
||||
<span class="text-sm font-mono text-slate-600">{{ $account->node?->ip_address ?? '161.97.138.149' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">Port:</span>
|
||||
<span class="text-sm font-mono text-slate-600">22</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">Username:</span>
|
||||
<span class="text-sm font-mono text-slate-600">{{ $account->username }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">Password:</span>
|
||||
<span class="text-sm text-slate-500">Set in Control Panel Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<a href="{{ $panelIndexUrl }}" class="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Open Control Panel
|
||||
</a>
|
||||
<a href="{{ $panelSettingsUrl }}" class="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||
Change SFTP Password
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,28 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', 'Billing — Ladill Hosting')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds hosting renewals and upgrades.</p>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-5 text-white shadow-sm sm:col-span-1">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Wallet balance</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-3 inline-block rounded-lg bg-white/15 px-3.5 py-1.5 text-xs font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on hosting</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-sm text-slate-600">Hosting plan renewals and upgrades are billed from your Ladill wallet. Manage individual accounts from the dashboard or account overview pages.</p>
|
||||
<p class="mt-2 text-xs text-slate-400">Keep your wallet funded to avoid hosting interruption.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,65 @@
|
||||
<x-app-layout title="Developers — Ladill Hosting">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your hosting programmatically.</p>
|
||||
|
||||
@if($newToken)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<p class="text-sm font-semibold text-emerald-900">Your new token — copy it now</p>
|
||||
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
|
||||
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied ✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
|
||||
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. CI server"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Generate token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
|
||||
@forelse($tokens as $token)
|
||||
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
Created {{ $token->created_at->diffForHumans() }} ·
|
||||
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('account.developers.destroy', $token->id) }}" onsubmit="return confirm('Revoke {{ $token->name }}?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
|
||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/me \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>. More coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,34 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', 'Settings — Ladill Hosting')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Hosting notifications and preferences.</p>
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Where we send hosting renewal reminders, suspension notices, and resource warnings.</p>
|
||||
<div class="mt-4">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" name="notify_email" value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<label class="flex items-center justify-between">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Product updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new Ladill Hosting features.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1" @checked($settings->product_updates ?? true) class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,80 @@
|
||||
<x-app-layout title="Team — Ladill Hosting">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this account’s hosting.</p>
|
||||
|
||||
@if($canManage)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
|
||||
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Email</label>
|
||||
<input type="email" name="email" required placeholder="teammate@example.com"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Role</label>
|
||||
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Send invite</button>
|
||||
</form>
|
||||
<p class="mt-2 text-[11px] text-slate-400">Admins can manage hosting and the team. Members can manage hosting. Invitees join by signing in with that email.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
|
||||
<ul class="divide-y divide-slate-50">
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $account->name ?? $account->email }} <span class="text-xs font-normal text-slate-400">(you)</span></p>
|
||||
<p class="text-xs text-slate-400">{{ $account->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
|
||||
</li>
|
||||
|
||||
@forelse($members as $member)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $member->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($member->status === 'invited')
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
|
||||
@endif
|
||||
@if($canManage)
|
||||
<form method="POST" action="{{ route('account.team.role', $member) }}">
|
||||
@csrf @method('PATCH')
|
||||
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
|
||||
<option value="member" @selected($member->role === 'member')>Member</option>
|
||||
<option value="admin" @selected($member->role === 'admin')>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('account.team.destroy', $member) }}" onsubmit="return confirm('Remove {{ $member->email }}?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,27 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', 'Wallet — Ladill Hosting')
|
||||
@section('content')
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds hosting and other Ladill apps.</p>
|
||||
|
||||
<div class="mt-6 rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-6 text-white shadow-sm">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Balance</p>
|
||||
<p class="mt-1 text-3xl font-semibold">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ $topupUrl }}" class="mt-4 inline-block rounded-lg bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on hosting</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-400">Hosting renewals and upgrades are billed from this wallet.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,82 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', $title . ' — Ladill Hosting')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('hosting.dashboard') }}" class="text-xs font-medium text-indigo-600 hover:underline">← Overview</a>
|
||||
<h1 class="mt-2 text-xl font-semibold tracking-tight text-slate-900">{{ $title }}</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Select a hosting account to manage</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach($accounts as $account)
|
||||
<a href="{{ route('hosting.accounts.show', $account) }}"
|
||||
class="group block rounded-xl border border-slate-200 bg-white p-5 hover:border-indigo-300 hover:shadow-md transition-all duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl
|
||||
@if($account->product?->type === 'wordpress') bg-sky-100
|
||||
@elseif($account->product?->type === 'multi_domain') bg-emerald-100
|
||||
@else bg-indigo-100 @endif">
|
||||
@if($account->product?->type === 'wordpress')
|
||||
@include('partials.wordpress-icon', ['class' => 'h-6 w-6'])
|
||||
@else
|
||||
<svg class="h-6 w-6
|
||||
@if($account->product?->type === 'multi_domain') text-emerald-600
|
||||
@else text-indigo-600 @endif"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
|
||||
{{ $account->linkedDomainLabel() ?? $account->username }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-sm text-slate-500">{{ $account->username }}</span>
|
||||
<span class="text-sm text-slate-400">·</span>
|
||||
<span class="text-sm text-slate-500">{{ $account->product?->name ?? 'Hosting' }}</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
@if($account->status === 'active') bg-emerald-100 text-emerald-700
|
||||
@elseif($account->status === 'suspended') bg-red-100 text-red-700
|
||||
@else bg-slate-100 text-slate-700 @endif">
|
||||
{{ ucfirst($account->status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right hidden sm:block">
|
||||
<p class="text-xs text-slate-500">{{ $account->sites->count() }} {{ Str::plural('domain', $account->sites->count()) }}</p>
|
||||
@if($account->expires_at)
|
||||
<p class="text-xs text-slate-400">Expires {{ $account->expires_at->format('M j, Y') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-slate-400 group-hover:text-indigo-600 group-hover:translate-x-1 transition-all" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-xl border border-dashed border-slate-300 bg-slate-50 p-6 text-center">
|
||||
<h3 class="font-medium text-slate-900">Need another hosting account?</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Choose a plan that fits your needs</p>
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-3">
|
||||
<a href="{{ route('hosting.single-domain') }}" class="inline-flex items-center rounded-lg bg-white border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition">
|
||||
Single Domain
|
||||
</a>
|
||||
<a href="{{ route('hosting.multi-domain') }}" class="inline-flex items-center rounded-lg bg-white border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition">
|
||||
Multi Domain
|
||||
</a>
|
||||
<a href="{{ route('hosting.wordpress') }}" class="inline-flex items-center rounded-lg bg-white border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition">
|
||||
@include('partials.wordpress-icon', ['class' => 'h-4 w-4 mr-1.5'])
|
||||
WordPress
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,103 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', 'Overview — Ladill Hosting')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your shared hosting at a glance.</p>
|
||||
</div>
|
||||
<a href="{{ route('hosting.single-domain') }}" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700">
|
||||
New hosting
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach([
|
||||
['Active accounts', $activeCount, route('hosting.accounts.index')],
|
||||
['Expiring (30 days)', $expiringCount, route('hosting.accounts.index')],
|
||||
['Pending orders', $pendingOrderCount, route('hosting.single-domain')],
|
||||
['Linked domains', $linkedDomains, route('hosting.accounts.index')],
|
||||
] as [$label, $value, $link])
|
||||
<a href="{{ $link }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">{{ $label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $value }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Hosting accounts</h2>
|
||||
@if ($accounts->count() > 5)
|
||||
<a href="{{ route('hosting.accounts.index') }}" class="text-xs font-medium text-indigo-600 hover:underline">View all</a>
|
||||
@endif
|
||||
</div>
|
||||
@if ($recent->isEmpty())
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No hosting accounts yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Choose a plan to get started — single domain, multi domain, or WordPress hosting.</p>
|
||||
<div class="mt-5 flex flex-wrap justify-center gap-2">
|
||||
<a href="{{ route('hosting.single-domain') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Single Domain</a>
|
||||
<a href="{{ route('hosting.multi-domain') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Multi Domain</a>
|
||||
<a href="{{ route('hosting.wordpress') }}" class="inline-flex items-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
@include('partials.wordpress-icon', ['class' => 'mr-1.5 h-4 w-4'])
|
||||
WordPress
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach ($recent as $account)
|
||||
@php
|
||||
$sharedDeveloperAccess = (int) $account->user_id !== (int) auth()->id();
|
||||
$accountUrl = $sharedDeveloperAccess
|
||||
? route('hosting.panel.index', $account)
|
||||
: route('hosting.accounts.show', $account);
|
||||
@endphp
|
||||
<a href="{{ $accountUrl }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-slate-900">{{ $account->linkedDomainLabel() ?? $account->username }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">
|
||||
{{ $account->username }}
|
||||
· {{ $account->product?->name ?? 'Hosting' }}
|
||||
· {{ $account->sites->count() }} {{ Str::plural('domain', $account->sites->count()) }}
|
||||
@if ($account->expires_at)
|
||||
· Expires {{ $account->expires_at->format('d M Y') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span @class([
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium capitalize',
|
||||
'bg-emerald-50 text-emerald-700' => $account->status === 'active',
|
||||
'bg-red-50 text-red-700' => $account->status === 'suspended',
|
||||
'bg-slate-100 text-slate-600' => ! in_array($account->status, ['active', 'suspended'], true),
|
||||
])>{{ $account->status }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pendingOrders->isNotEmpty())
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Pending orders</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach ($pendingOrders as $order)
|
||||
<a href="{{ route('hosting.orders.show', $order) }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-slate-900">{{ $order->domain_name ?: ($order->product?->name ?? 'Hosting order') }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $order->product?->name ?? 'Hosting' }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">
|
||||
{{ \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($order->status) }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,255 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $order->domain_name }} - Order Details</x-slot>
|
||||
|
||||
@php
|
||||
$statusColors = [
|
||||
'pending_payment' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'pending_approval' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'approved' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'active' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'suspended' => 'bg-red-50 text-red-700 border-red-200',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
'expired' => 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
'failed' => 'bg-red-50 text-red-700 border-red-200',
|
||||
];
|
||||
|
||||
$statusLabels = collect([
|
||||
'pending_payment', 'pending_approval', 'approved', 'provisioning',
|
||||
'active', 'suspended', 'cancelled', 'expired', 'failed',
|
||||
])->mapWithKeys(fn (string $status) => [
|
||||
$status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status),
|
||||
])->all();
|
||||
$statusColors['approved'] = 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
|
||||
$backRoute = match ($order->product?->type) {
|
||||
'single_domain' => route('hosting.single-domain'),
|
||||
'multi_domain' => route('hosting.multi-domain'),
|
||||
'wordpress' => route('hosting.wordpress'),
|
||||
default => route('hosting.single-domain'),
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{ showCancelModal: false }">
|
||||
{{-- Page Header --}}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<a href="{{ $backRoute }}" class="mb-2 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
Back to {{ $order->product?->type === 'wordpress' ? 'WordPress Hosting' : ($order->product?->type === 'multi_domain' ? 'Multi Domain Hosting' : 'Single Domain Hosting') }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $order->domain_name }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $order->product?->name ?? 'Hosting Order' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-lg border px-3 py-1.5 text-sm font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500 border-gray-200' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
{{-- Main Content --}}
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
{{-- Order Details --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Order Details</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Domain</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->domain_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Plan</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->product?->name ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Billing Cycle</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ ucfirst($order->billing_cycle) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Amount</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ strtoupper($order->currency) }} {{ number_format($order->amount_paid, 2) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Created</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->created_at->format('M d, Y H:i') }}</span>
|
||||
</div>
|
||||
@if ($order->expires_at)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Expires</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->expires_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Server Details (if active) --}}
|
||||
@if ($order->isActive() && ($order->server_ip || $order->server_username || $order->control_panel_url))
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Server Details</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@if ($order->server_ip)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">IP Address</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->server_ip }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($order->server_username)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Username</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->server_username }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($order->hostingNode)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Server</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->hostingNode->hostname }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($order->control_panel_url)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Control Panel</span>
|
||||
<a href="{{ $order->control_panel_url }}" target="_blank" class="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||
Open cPanel →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Plan Resources --}}
|
||||
@if ($order->product)
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Plan Resources</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 p-5 sm:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->formatDiskSize() }}</p>
|
||||
<p class="text-xs text-gray-500">Storage</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->formatBandwidth() }}</p>
|
||||
<p class="text-xs text-gray-500">Bandwidth</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->max_domains ?? '∞' }}</p>
|
||||
<p class="text-xs text-gray-500">Domains</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->max_databases ?? '∞' }}</p>
|
||||
<p class="text-xs text-gray-500">Databases</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Quick Actions --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Quick Actions</h2>
|
||||
</div>
|
||||
<div class="space-y-2 p-5">
|
||||
@if ($order->control_panel_url && $order->isActive())
|
||||
<a href="{{ $order->control_panel_url }}" target="_blank"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
Open Control Panel
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ ladill_account_url('/support-tickets/create') }}"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/></svg>
|
||||
Get Support
|
||||
</a>
|
||||
@if ($order->canBeCancelled())
|
||||
<button @click="showCancelModal = true" type="button"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
Cancel Order
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Status Timeline --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Status</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full {{ $order->created_at ? 'bg-emerald-100' : 'bg-gray-100' }}">
|
||||
<svg class="h-3 w-3 {{ $order->created_at ? 'text-emerald-600' : 'text-gray-400' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Order Placed</p>
|
||||
<p class="text-xs text-gray-500">{{ $order->created_at->format('M d, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full {{ $order->approved_at ? 'bg-emerald-100' : 'bg-gray-100' }}">
|
||||
@if ($order->approved_at)
|
||||
<svg class="h-3 w-3 text-emerald-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
@else
|
||||
<div class="h-2 w-2 rounded-full bg-gray-300"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium {{ $order->approved_at ? 'text-gray-900' : 'text-gray-400' }}">Approved</p>
|
||||
@if ($order->approved_at)
|
||||
<p class="text-xs text-gray-500">{{ $order->approved_at->format('M d, Y H:i') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full {{ $order->provisioned_at ? 'bg-emerald-100' : 'bg-gray-100' }}">
|
||||
@if ($order->provisioned_at)
|
||||
<svg class="h-3 w-3 text-emerald-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
@else
|
||||
<div class="h-2 w-2 rounded-full bg-gray-300"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium {{ $order->provisioned_at ? 'text-gray-900' : 'text-gray-400' }}">Provisioned</p>
|
||||
@if ($order->provisioned_at)
|
||||
<p class="text-xs text-gray-500">{{ $order->provisioned_at->format('M d, Y H:i') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cancel Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="showCancelModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="showCancelModal = false">
|
||||
<div x-show="showCancelModal" x-transition class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm hidden sm:block" @click="showCancelModal = false"></div>
|
||||
<div x-show="showCancelModal" x-transition class="relative min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-xl sm:border sm:border-gray-200 bg-white p-6 shadow-xl" @click.stop>
|
||||
<button @click="showCancelModal = false" type="button" class="absolute top-3 right-3 z-10 flex h-8 w-8 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 sm:hidden"><svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg></button>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Cancel Order</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">Are you sure you want to cancel this order? This action cannot be undone.</p>
|
||||
<form action="{{ route('hosting.orders.cancel', $order) }}" method="POST" class="mt-4">
|
||||
@csrf
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button @click="showCancelModal = false" type="button" class="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">Keep Order</button>
|
||||
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">Cancel Order</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,551 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Application Installer - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Application Installer</x-slot>
|
||||
|
||||
<div class="space-y-6" x-data="{ selectedApp: '' }">
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning');
|
||||
@endphp
|
||||
|
||||
@if($sslProvisioningSites->isNotEmpty())
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-amber-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-amber-800">SSL Certificate Provisioning</h3>
|
||||
<p class="mt-1 text-sm text-amber-700">
|
||||
SSL certificates are being provisioned for
|
||||
<strong>{{ $sslProvisioningSites->pluck('domain')->join(', ') }}</strong>.
|
||||
Please wait until this completes before installing applications. This usually takes 1-2 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Active Installations In Progress (at top for visibility) --}}
|
||||
@php
|
||||
$pendingInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id'))
|
||||
->where('status', 'installing')
|
||||
->with('site')
|
||||
->get();
|
||||
@endphp
|
||||
@if($pendingInstallations->count() > 0)
|
||||
<div x-data="installationProgress({{ $pendingInstallations->map(fn($p) => ['id' => $p->id, 'app_type' => $p->app_type, 'domain' => $p->site->domain, 'progress' => $p->progress, 'progress_message' => $p->progress_message])->toJson() }}, {{ $account->id }})">
|
||||
<template x-for="item in installationItems" :key="item.id">
|
||||
<div class="rounded-xl border overflow-hidden mb-4 transition-all duration-500"
|
||||
:class="item.status === 'active' ? 'border-emerald-200 bg-emerald-50' : item.status === 'failed' ? 'border-red-200 bg-red-50' : 'border-indigo-200 bg-indigo-50'">
|
||||
<div class="px-6 py-4 border-b transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'border-emerald-200 bg-emerald-100/50' : item.status === 'failed' ? 'border-red-200 bg-red-100/50' : 'border-indigo-200 bg-indigo-100/50'">
|
||||
<h3 class="text-base font-semibold flex items-center gap-2 transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'text-emerald-900' : item.status === 'failed' ? 'text-red-900' : 'text-indigo-900'">
|
||||
<template x-if="item.status === 'installing'">
|
||||
<svg class="h-5 w-5 animate-spin text-indigo-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="item.status === 'active'">
|
||||
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</template>
|
||||
<template x-if="item.status === 'failed'">
|
||||
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</template>
|
||||
<span x-text="item.status === 'active' ? 'Installation Complete!' : item.status === 'failed' ? 'Installation Failed' : 'Installing...'"></span>
|
||||
</h3>
|
||||
<p class="text-sm mt-1 transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'text-emerald-700' : item.status === 'failed' ? 'text-red-700' : 'text-indigo-700'"
|
||||
x-text="item.status === 'active' ? 'Your application is ready to use.' : item.status === 'failed' ? 'Something went wrong during installation.' : 'This may take a few minutes.'"></p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-white flex items-center justify-center flex-shrink-0 transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'border-emerald-200 border' : item.status === 'failed' ? 'border-red-200 border' : 'border-indigo-200 border'">
|
||||
<img :src="'/images/app_logos/' + item.app_type + '.svg'" :alt="item.app_type" class="h-7 w-7">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'text-emerald-900' : item.status === 'failed' ? 'text-red-900' : 'text-indigo-900'"
|
||||
x-text="item.app_type.charAt(0).toUpperCase() + item.app_type.slice(1)"></p>
|
||||
<p class="text-xs transition-colors duration-500"
|
||||
:class="item.status === 'active' ? 'text-emerald-600' : item.status === 'failed' ? 'text-red-600' : 'text-indigo-600'"
|
||||
x-text="item.domain"></p>
|
||||
</div>
|
||||
<div class="text-right" x-show="item.status === 'installing'">
|
||||
<p class="text-2xl font-bold text-indigo-600" x-text="item.progress + '%'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Progress bar (only during installation) --}}
|
||||
<div class="space-y-2" x-show="item.status === 'installing'" x-transition>
|
||||
<div class="h-2 w-full bg-indigo-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-indigo-600 rounded-full transition-all duration-500 ease-out"
|
||||
:style="'width: ' + item.progress + '%'"></div>
|
||||
</div>
|
||||
<p class="text-xs text-indigo-700" x-text="item.progress_message"></p>
|
||||
</div>
|
||||
|
||||
{{-- Success actions --}}
|
||||
<div class="flex items-center gap-3 pt-2" x-show="item.status === 'active'" x-transition>
|
||||
<a :href="'https://' + item.domain" target="_blank" class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
Visit Your Site
|
||||
</a>
|
||||
<button @click="item.dismissed = true; $nextTick(() => { if (installationItems.every(i => i.dismissed)) window.location.reload(); })" class="inline-flex items-center gap-1 text-sm text-emerald-700 hover:text-emerald-900">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Failed actions --}}
|
||||
<div class="pt-2" x-show="item.status === 'failed'" x-transition>
|
||||
<p class="text-xs text-red-600 mb-3" x-text="item.error_message || 'Please try again or contact support if the issue persists.'"></p>
|
||||
<button @click="window.location.reload()" class="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Failed Installations --}}
|
||||
@php
|
||||
$failedInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id'))
|
||||
->where('status', 'failed')
|
||||
->with('site')
|
||||
->get();
|
||||
@endphp
|
||||
@if($failedInstallations->count() > 0)
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-red-200 bg-red-100/50">
|
||||
<h3 class="text-base font-semibold text-red-900 flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Failed Installations
|
||||
</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-red-200">
|
||||
@foreach($failedInstallations as $failed)
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-10 w-10 rounded-lg bg-white border border-red-200 flex items-center justify-center flex-shrink-0">
|
||||
<img src="{{ asset('images/app_logos/' . $failed->app_type . '.svg') }}" alt="{{ ucfirst($failed->app_type) }}" class="h-6 w-6 opacity-50">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-red-900">{{ ucfirst($failed->app_type) }} on {{ $failed->site->domain }}</p>
|
||||
<p class="text-xs text-red-600 mt-0.5">{{ $failed->error_message ?: 'Installation failed. Please try again.' }}</p>
|
||||
</div>
|
||||
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="flex-shrink-0">
|
||||
@csrf
|
||||
<input type="hidden" name="app" value="{{ $failed->app_type }}">
|
||||
<input type="hidden" name="site_id" value="{{ $failed->hosted_site_id }}">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Retry
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($isWordPressPlan))
|
||||
{{-- WordPress-Only Installer --}}
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="relative bg-gradient-to-br from-[#21759b]/5 via-white to-[#21759b]/5">
|
||||
<div class="px-6 py-8 sm:px-8">
|
||||
<div class="flex flex-col items-center text-center max-w-md mx-auto">
|
||||
<div class="h-16 w-16 rounded-2xl bg-gradient-to-br from-[#21759b]/10 to-[#21759b]/20 flex items-center justify-center mb-5">
|
||||
@include('partials.wordpress-icon', ['class' => 'h-10 w-10'])
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-slate-900">Install WordPress</h3>
|
||||
<p class="text-sm text-slate-500 mt-1.5">Your hosting plan is optimised for WordPress. Install it in one click with a database and admin credentials set up automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="border-t border-slate-200 px-6 py-6 sm:px-8 space-y-5">
|
||||
@csrf
|
||||
<input type="hidden" name="app" value="wordpress">
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Install To Domain</label>
|
||||
@if($account->sites->count() > 0)
|
||||
<select name="site_id" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
|
||||
@foreach($account->sites as $site)
|
||||
<option value="{{ $site->id }}">{{ $site->domain }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<p class="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg p-3">You need to <a href="{{ route('hosting.panel.domains', $account) }}" class="underline font-medium">add a domain</a> first.</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">PHP Version</label>
|
||||
<select name="php_version" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
|
||||
<option value="8.4">PHP 8.4 (Recommended)</option>
|
||||
<option value="8.3">PHP 8.3</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Subdirectory <span class="text-slate-400 font-normal">(optional)</span></label>
|
||||
<input type="text" name="directory" placeholder="Leave empty to install at domain root" pattern="[a-zA-Z0-9_-]*" maxlength="100" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1.5 text-xs text-slate-500">Install in a subfolder instead of the domain root.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-[#21759b] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#1a5f7f] transition shadow-sm">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Install WordPress
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
{{-- Available Applications - Modern Grid --}}
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Available Applications</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">One-click installation for popular CMS platforms and frameworks.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach($availableApps as $app)
|
||||
<div @click="selectedApp = '{{ $app['id'] }}'; $nextTick(() => { document.getElementById('install-form').scrollIntoView({ behavior: 'smooth', block: 'center' }); })" class="group relative rounded-xl border border-slate-200 bg-white p-5 hover:border-indigo-300 hover:shadow-md transition-all duration-200 cursor-pointer">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="h-14 w-14 rounded-xl bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center mb-4 group-hover:scale-105 transition-transform">
|
||||
<img src="{{ asset('images/app_logos/' . $app['id'] . '.svg') }}" alt="{{ $app['name'] }}" class="h-9 w-9">
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-slate-900">{{ $app['name'] }}</h4>
|
||||
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ $app['description'] }}</p>
|
||||
<div class="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">{{ ucfirst($app['category']) }}</span>
|
||||
@if(in_array($app['id'], ['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel']))
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-[10px] font-medium text-indigo-600">PHP</span>
|
||||
@elseif($app['id'] === 'nodejs')
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-[10px] font-medium text-green-600">JavaScript</span>
|
||||
@elseif($app['id'] === 'python')
|
||||
<span class="inline-flex items-center rounded-full bg-yellow-50 px-2 py-0.5 text-[10px] font-medium text-yellow-700">Python</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Install Application Form --}}
|
||||
<div id="install-form" class="rounded-xl border border-slate-200 bg-white p-6 scroll-mt-6">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="h-10 w-10 rounded-lg bg-indigo-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">Install New Application</h3>
|
||||
<p class="text-sm text-slate-500 mt-0.5">CMS applications automatically provision a database and generate admin credentials.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="space-y-5">
|
||||
@csrf
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Application</label>
|
||||
<select name="app" x-model="selectedApp" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
|
||||
<option value="">Select an application...</option>
|
||||
@foreach($availableApps as $app)
|
||||
<option value="{{ $app['id'] }}">{{ $app['name'] }} — {{ $app['description'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Install To Domain</label>
|
||||
@if($account->sites->count() > 0)
|
||||
<select name="site_id" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
|
||||
@foreach($account->sites as $site)
|
||||
<option value="{{ $site->id }}">{{ $site->domain }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<p class="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg p-3">You need to <a href="{{ route('hosting.panel.domains', $account) }}" class="underline font-medium">add a domain</a> first.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
{{-- PHP Version Selector (only for PHP apps) --}}
|
||||
<div x-show="['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel'].includes(selectedApp)" x-transition>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">PHP Version</label>
|
||||
<select name="php_version" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
|
||||
<option value="8.4">PHP 8.4 (Default)</option>
|
||||
<option value="8.3">PHP 8.3</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-slate-500">
|
||||
<span x-show="selectedApp === 'magento'" class="text-amber-600 font-medium">Magento requires PHP 8.1, 8.2, or 8.3</span>
|
||||
<span x-show="selectedApp !== 'magento'">Select the PHP version for your application. Most apps work best with PHP 8.3 or 8.4.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Subdirectory <span class="text-slate-400 font-normal">(optional)</span></label>
|
||||
<input type="text" name="directory" placeholder="e.g., blog, shop (leave empty for root)" pattern="[a-zA-Z0-9_-]*" maxlength="100" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1.5 text-xs text-slate-500">Install in a subfolder instead of the domain root.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition shadow-sm">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
Install Application
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Current Installations --}}
|
||||
@php
|
||||
$installedSites = $account->sites->filter(fn($site) => $site->installed_app)->load('appInstallation');
|
||||
@endphp
|
||||
@if($installedSites->count() > 0)
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Current Installations</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">View your application credentials and access details below.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200">
|
||||
@foreach($installedSites as $site)
|
||||
<div class="p-6" x-data="{ showCredentials: false }">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<img src="{{ asset('images/app_logos/' . $site->installed_app . '.svg') }}" alt="{{ ucfirst($site->installed_app) }}" class="h-6 w-6" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<svg class="h-6 w-6 text-slate-400 hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ ucfirst($site->installed_app) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $site->domain }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($site->installed_app_version)
|
||||
<span class="text-xs text-slate-500">v{{ $site->installed_app_version }}</span>
|
||||
@endif
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">Active</span>
|
||||
<a href="https://{{ $site->domain }}" target="_blank" class="text-xs text-indigo-600 hover:text-indigo-800">Visit Site →</a>
|
||||
@if($site->appInstallation)
|
||||
<button @click="showCredentials = !showCredentials" class="inline-flex items-center gap-1 text-xs text-slate-600 hover:text-slate-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path x-show="!showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path x-show="!showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
<path x-show="showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
<span x-text="showCredentials ? 'Hide' : 'Show'"></span> Credentials
|
||||
</button>
|
||||
@endif
|
||||
<form action="{{ route('hosting.panel.apps.delete', [$account, $site]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this {{ ucfirst($site->installed_app) }} installation? This will remove all application files and cannot be undone.');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Credentials Panel --}}
|
||||
@if($site->appInstallation)
|
||||
<div x-show="showCredentials" x-collapse class="mt-4 rounded-lg bg-slate-50 border border-slate-200 p-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{{-- Admin Credentials --}}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-slate-700 flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
Admin Access
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Admin URL</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ $site->appInstallation->admin_url }}" target="_blank" class="text-sm text-indigo-600 hover:text-indigo-800 font-mono break-all">{{ $site->appInstallation->admin_url }}</a>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_url }}')" class="text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Username</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->admin_username }}</span>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_username }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Password</label>
|
||||
@if($site->appInstallation->admin_password)
|
||||
<div class="flex items-center gap-2" x-data="{ showPassword: false }">
|
||||
<span class="text-sm font-mono text-slate-900" x-text="showPassword ? '{{ $site->appInstallation->admin_password }}' : '••••••••••••'"></span>
|
||||
<button @click="showPassword = !showPassword" class="text-slate-400 hover:text-slate-600" title="Toggle visibility">
|
||||
<svg x-show="!showPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
<svg x-show="showPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
|
||||
</button>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_password }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-500 italic">Use the application's password reset if needed</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Database Credentials --}}
|
||||
@if($site->appInstallation->database_name)
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-slate-700 flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg>
|
||||
Database Access
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Database Name</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->database_name }}</span>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_name }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Username</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->database_username }}</span>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_username }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-0.5">Password</label>
|
||||
@if($site->appInstallation->database_password)
|
||||
<div class="flex items-center gap-2" x-data="{ showDbPassword: false }">
|
||||
<span class="text-sm font-mono text-slate-900" x-text="showDbPassword ? '{{ $site->appInstallation->database_password }}' : '••••••••••••'"></span>
|
||||
<button @click="showDbPassword = !showDbPassword" class="text-slate-400 hover:text-slate-600" title="Toggle visibility">
|
||||
<svg x-show="!showDbPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
<svg x-show="showDbPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
|
||||
</button>
|
||||
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_password }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-500 italic">Not available</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-t border-slate-200">
|
||||
<p class="text-xs text-amber-600 flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Keep these credentials secure. We recommend changing your password after first login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function installationProgress(initialItems, accountId) {
|
||||
return {
|
||||
installationItems: initialItems.map(item => ({
|
||||
...item,
|
||||
status: 'installing',
|
||||
dismissed: false,
|
||||
error_message: null
|
||||
})),
|
||||
polling: null,
|
||||
|
||||
init() {
|
||||
if (this.installationItems.length === 0) return;
|
||||
|
||||
// Start polling
|
||||
this.poll();
|
||||
this.polling = setInterval(() => this.poll(), 2000);
|
||||
},
|
||||
|
||||
async poll() {
|
||||
let hasInstalling = false;
|
||||
|
||||
for (const item of this.installationItems) {
|
||||
if (item.status !== 'installing') continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/hosting/panel/${accountId}/apps/${item.id}/progress`);
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = await response.json();
|
||||
item.progress = data.progress;
|
||||
item.progress_message = data.progress_message;
|
||||
|
||||
if (data.status === 'active') {
|
||||
item.status = 'active';
|
||||
item.progress = 100;
|
||||
item.progress_message = 'Installation complete!';
|
||||
} else if (data.status === 'failed') {
|
||||
item.status = 'failed';
|
||||
item.error_message = data.error_message;
|
||||
} else {
|
||||
hasInstalling = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch progress:', e);
|
||||
hasInstalling = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInstalling && this.polling) {
|
||||
clearInterval(this.polling);
|
||||
this.polling = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.polling) {
|
||||
clearInterval(this.polling);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,136 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Cron Jobs - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Cron Jobs</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Cron Job</h3>
|
||||
<form action="{{ route('hosting.panel.cron.add', $account) }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
<div class="grid gap-4 sm:grid-cols-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Minute</label>
|
||||
<input type="text" name="minute" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-[10px] text-slate-500">0-59 or *</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Hour</label>
|
||||
<input type="text" name="hour" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-[10px] text-slate-500">0-23 or *</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Day</label>
|
||||
<input type="text" name="day" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-[10px] text-slate-500">1-31 or *</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Month</label>
|
||||
<input type="text" name="month" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-[10px] text-slate-500">1-12 or *</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Weekday</label>
|
||||
<input type="text" name="weekday" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-[10px] text-slate-500">0-6 or *</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Command</label>
|
||||
<input type="text" name="command" required placeholder="/usr/bin/php /home/{{ $account->username }}/public_html/artisan schedule:run" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Use full paths for commands and scripts</p>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add Cron Job
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-2">Common Schedules</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Every minute</p>
|
||||
<code class="text-xs font-mono text-slate-600">* * * * *</code>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Every 5 minutes</p>
|
||||
<code class="text-xs font-mono text-slate-600">*/5 * * * *</code>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Every hour</p>
|
||||
<code class="text-xs font-mono text-slate-600">0 * * * *</code>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Daily at midnight</p>
|
||||
<code class="text-xs font-mono text-slate-600">0 0 * * *</code>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Weekly (Sunday)</p>
|
||||
<code class="text-xs font-mono text-slate-600">0 0 * * 0</code>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-xs font-medium text-slate-700">Monthly</p>
|
||||
<code class="text-xs font-mono text-slate-600">0 0 1 * *</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Active Cron Jobs</h3>
|
||||
</div>
|
||||
@if(count($cronJobs))
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Schedule</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Command</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($cronJobs as $job)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm font-mono text-slate-700 bg-slate-100 px-2 py-1 rounded">{{ $job['schedule'] }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-sm font-mono text-slate-600 break-all">{{ $job['command'] }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<form action="{{ route('hosting.panel.cron.delete', $account) }}" method="POST" onsubmit="return confirm('Delete this cron job?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="line_number" value="{{ $job['line_number'] }}">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p class="mt-4 text-sm text-slate-500">No cron jobs configured yet.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,148 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Databases - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Databases</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Create New Database</h3>
|
||||
<form action="{{ route('hosting.panel.databases.create', $account) }}" method="POST" class="flex flex-wrap items-end gap-4">
|
||||
@csrf
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Database Name</label>
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center rounded-l-lg border border-r-0 border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-500">{{ substr($account->username, 0, 8) }}_</span>
|
||||
<input type="text" name="name" required pattern="[a-zA-Z0-9_]+" maxlength="24" placeholder="mydb" class="flex-1 rounded-r-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Create Database
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Your Databases</h3>
|
||||
</div>
|
||||
@if($account->databases->count())
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Database Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Host</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($account->databases as $database)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-900">{{ $database->name }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-600">{{ $database->username }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-600">{{ $database->host }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@if($phpMyAdminUrl)
|
||||
<a href="{{ $phpMyAdminUrl }}" target="_blank" class="inline-flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
|
||||
phpMyAdmin
|
||||
</a>
|
||||
@endif
|
||||
<button onclick="resetPassword({{ $database->id }})" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||
Reset Password
|
||||
</button>
|
||||
<form action="{{ route('hosting.panel.databases.delete', [$account, $database]) }}" method="POST" onsubmit="return confirm('Are you sure? This will permanently delete the database and all its data.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
|
||||
<p class="mt-4 text-sm text-slate-500">No databases yet. Create one above.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Connection Information</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Host</p>
|
||||
<p class="mt-1 text-sm font-mono text-slate-900">localhost</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Port</p>
|
||||
<p class="mt-1 text-sm font-mono text-slate-900">3306</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">External Access</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Not available (localhost only)</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">phpMyAdmin</p>
|
||||
@if($phpMyAdminUrl)
|
||||
<a href="{{ $phpMyAdminUrl }}" target="_blank" rel="noopener noreferrer" class="mt-1 inline-flex items-center gap-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
Open phpMyAdmin
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H18m0 0v4.5M18 6l-7.5 7.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M6 18h12a2 2 0 002-2V9.75m-16 0V16a2 2 0 002 2"/></svg>
|
||||
</a>
|
||||
<p class="mt-1 text-xs text-slate-500">Use your database username and password to log in.</p>
|
||||
@else
|
||||
<p class="mt-1 text-sm text-slate-500">phpMyAdmin is not configured for this server.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function resetPassword(databaseId) {
|
||||
if (!confirm('Reset the database password? You will need to update your application configuration.')) return;
|
||||
try {
|
||||
const res = await fetch(`{{ url('hosting/panel/' . $account->id . '/databases') }}/${databaseId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('New password: ' + data.password + '\n\nPlease save this password - it will not be shown again.');
|
||||
} else {
|
||||
alert(data.error || 'Failed to reset password');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,256 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Domains - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Domains</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
|
||||
$oldDomain = trim((string) old('domain', ''));
|
||||
$initialDomainSource = $ownedDomains->isEmpty()
|
||||
? 'external'
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$externalFallbackDomain = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Domain</h3>
|
||||
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
|
||||
x-data="{
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwnedDomain),
|
||||
externalDomain: @js($externalFallbackDomain),
|
||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
|
||||
}">
|
||||
@csrf
|
||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
||||
|
||||
{{-- Domain Source Toggle --}}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-slate-700">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ladill (Owned) Domains --}}
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
@if($ownedDomains->isNotEmpty())
|
||||
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
|
||||
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Choose a domain...</option>
|
||||
@foreach($ownedDomains as $domain)
|
||||
<option value="{{ $domain }}">{{ $domain }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-slate-500">
|
||||
Don't have a domain?
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
|
||||
</p>
|
||||
@else
|
||||
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
|
||||
No domains found in your account.
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- External Domain --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input type="text" name="domain" x-model="externalDomain" :disabled="domainSource !== 'external'" required placeholder="example.com"
|
||||
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('domain') border-red-300 @enderror">
|
||||
<p class="mt-1.5 text-xs text-slate-500">Enter your domain without www or http (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
@error('domain')
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Onboarding Mode (External only) --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-3">Connection Method</label>
|
||||
<div class="space-y-3">
|
||||
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
|
||||
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Point Nameservers</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
|
||||
</label>
|
||||
|
||||
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
|
||||
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Add DNS Records</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
|
||||
</label>
|
||||
</div>
|
||||
@error('onboarding_mode')
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Document Root (optional, both flows) --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Document Root <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
|
||||
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add Domain
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
||||
</div>
|
||||
@if($account->sites->count())
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Document Root</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($account->sites as $site)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-slate-900">{{ $site->domain }}</span>
|
||||
<a href="http://{{ $site->domain }}" target="_blank" class="text-slate-400 hover:text-indigo-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $site->status === 'active' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-800' }}">
|
||||
{{ ucfirst($site->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
@if((int) $account->user_id === (int) auth()->id())
|
||||
<form action="{{ route('hosting.panel.domains.remove', [$account, $site]) }}" method="POST" onsubmit="return confirm('Remove this domain? The files will not be deleted.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-xs text-slate-400">Owner only</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
|
||||
<p class="mt-4 text-sm text-slate-500">No addon domains yet. Add one above.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
@endphp
|
||||
|
||||
{{-- DNS Quick Reference --}}
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-2">DNS Quick Reference</h3>
|
||||
<p class="text-sm text-slate-600 mb-4">Use one of these methods to connect an external domain:</p>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50/50 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-sm font-semibold text-slate-900">Nameservers</h4>
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-800">Recommended</span>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
|
||||
<span class="text-sm font-mono text-slate-900">ns1.ladill.com</span>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('ns1.ladill.com')" class="text-xs text-indigo-600 hover:text-indigo-800">Copy</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
|
||||
<span class="text-sm font-mono text-slate-900">ns2.ladill.com</span>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('ns2.ladill.com')" class="text-xs text-indigo-600 hover:text-indigo-800">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
||||
<h4 class="text-sm font-semibold text-slate-900 mb-2">A Records</h4>
|
||||
<div class="space-y-1 rounded bg-white border border-slate-200 p-3">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-medium text-slate-500">Type</span>
|
||||
<span class="font-medium text-slate-500">Name</span>
|
||||
<span class="font-medium text-slate-500">Value</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-mono text-slate-900">A</span>
|
||||
<span class="font-mono text-slate-900">@</span>
|
||||
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-mono text-slate-900">A</span>
|
||||
<span class="font-mono text-slate-900">www</span>
|
||||
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 24–48 hours.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,782 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">File Manager - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">File Manager</x-slot>
|
||||
|
||||
<div x-data="fileManager()" class="space-y-6">
|
||||
{{-- Action Feedback --}}
|
||||
<template x-teleport="body">
|
||||
<div class="fixed right-4 top-4 z-[70] w-[calc(100%-2rem)] max-w-sm space-y-2 sm:right-6 sm:top-6 sm:w-full" aria-live="polite" aria-atomic="true">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div
|
||||
x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-x-2 sm:translate-y-0"
|
||||
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="rounded-xl border bg-white p-4 shadow-lg"
|
||||
:class="toast.type === 'success' ? 'border-emerald-200' : (toast.type === 'error' ? 'border-red-200' : 'border-indigo-200')"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 rounded-full p-1"
|
||||
:class="toast.type === 'success' ? 'bg-emerald-100 text-emerald-600' : (toast.type === 'error' ? 'bg-red-100 text-red-600' : 'bg-indigo-100 text-indigo-600')">
|
||||
<svg x-show="toast.type === 'success'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
<svg x-show="toast.type === 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
|
||||
<svg x-show="toast.type !== 'success' && toast.type !== 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/></svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900" x-text="toast.title"></p>
|
||||
<p class="mt-0.5 text-sm text-slate-600" x-text="toast.message"></p>
|
||||
</div>
|
||||
<button type="button" @click="dismissToast(toast.id)" class="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="actionStatus.message" x-cloak class="rounded-xl border p-4"
|
||||
:class="actionStatus.type === 'success' ? 'border-emerald-200 bg-emerald-50' : (actionStatus.type === 'error' ? 'border-red-200 bg-red-50' : 'border-indigo-200 bg-indigo-50')">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold"
|
||||
:class="actionStatus.type === 'success' ? 'text-emerald-800' : (actionStatus.type === 'error' ? 'text-red-800' : 'text-indigo-800')"
|
||||
x-text="actionStatus.title"></p>
|
||||
<p class="mt-0.5 text-sm"
|
||||
:class="actionStatus.type === 'success' ? 'text-emerald-700' : (actionStatus.type === 'error' ? 'text-red-700' : 'text-indigo-700')"
|
||||
x-text="actionStatus.message"></p>
|
||||
</div>
|
||||
<button type="button" @click="clearStatus()" class="rounded-md p-1 text-slate-400 hover:bg-white/70 hover:text-slate-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<button @click="createModal = true; createType = 'folder'" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
|
||||
New Folder
|
||||
</button>
|
||||
<button @click="createModal = true; createType = 'file'" class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||
New File
|
||||
</button>
|
||||
<label class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition cursor-pointer">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
|
||||
Upload
|
||||
<input type="file" class="hidden" @change="uploadFile($event)" multiple>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Upload Progress --}}
|
||||
<div x-show="uploading" x-cloak class="rounded-xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-indigo-700">Uploading <span x-text="uploadFileName"></span></span>
|
||||
<span class="text-sm font-semibold text-indigo-700" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-indigo-100 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
<p x-show="uploadProgress >= 100" class="mt-2 text-xs text-indigo-600">Processing file on server...</p>
|
||||
</div>
|
||||
|
||||
{{-- Extraction Progress --}}
|
||||
<div x-show="extracting" x-cloak class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 text-emerald-600 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-emerald-700">Extracting <span x-text="extractFileName"></span></span>
|
||||
<p class="text-xs text-emerald-600 mt-0.5">This may take a while for large archives...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bulk Action Bar --}}
|
||||
<div x-show="selected.length > 0" x-cloak class="flex flex-wrap items-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3">
|
||||
<span class="text-sm font-medium text-indigo-700 mr-1" x-text="selected.length + ' selected'"></span>
|
||||
<div class="h-5 w-px bg-indigo-200"></div>
|
||||
<button @click="bulkAction('move')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
|
||||
Move
|
||||
</button>
|
||||
<button @click="bulkAction('copy')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
<button @click="bulkAction('compress')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/></svg>
|
||||
Compress
|
||||
</button>
|
||||
<button @click="bulkAction('chmod')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
Permissions
|
||||
</button>
|
||||
<button @click="bulkAction('delete')" class="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 border border-red-200 hover:bg-red-100 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
<div class="ml-auto">
|
||||
<button @click="selected = []" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Breadcrumbs --}}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
@foreach($breadcrumbs as $index => $crumb)
|
||||
@if($index > 0)
|
||||
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
|
||||
@endif
|
||||
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $crumb['path']]) }}" class="text-slate-600 hover:text-indigo-600 transition {{ $loop->last ? 'font-medium text-slate-900' : '' }}">{{ $crumb['name'] }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- File Table --}}
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="w-10 px-4 py-3"><input type="checkbox" @change="toggleAll($event)" :checked="selected.length === allPaths.length && allPaths.length > 0" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Size</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Modified</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@forelse($files as $file)
|
||||
<tr class="hover:bg-slate-50 transition" :class="{ 'bg-indigo-50/50': selected.includes('{{ $file['path'] }}') }">
|
||||
<td class="w-10 px-4 py-3"><input type="checkbox" value="{{ $file['path'] }}" x-model="selected" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($file['type'] === 'folder')
|
||||
<svg class="h-5 w-5 text-amber-500 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M19.5 21a3 3 0 003-3v-4.5a3 3 0 00-3-3h-15a3 3 0 00-3 3V18a3 3 0 003 3h15zM1.5 10.146V6a3 3 0 013-3h5.379a2.25 2.25 0 011.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 013 3v1.146A4.483 4.483 0 0019.5 9h-15a4.483 4.483 0 00-3 1.146z"/></svg>
|
||||
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $file['path']]) }}" class="text-sm font-medium text-slate-900 hover:text-indigo-600">{{ $file['name'] }}</a>
|
||||
@else
|
||||
<svg class="h-5 w-5 text-slate-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||
<span class="text-sm text-slate-900">{{ $file['name'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['size_formatted'] }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['modified'] }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if($file['type'] === 'file')
|
||||
@if(in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['zip', 'tar', 'tgz']) || str_ends_with(strtolower($file['name']), '.tar.gz') || str_ends_with(strtolower($file['name']), '.tar.bz2'))
|
||||
<button @click="extractFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="rounded px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-300 hover:bg-emerald-50 transition" title="Unzip / Extract archive">
|
||||
Unzip
|
||||
</button>
|
||||
@endif
|
||||
<button @click="editFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-indigo-600 transition" title="Edit">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/></svg>
|
||||
</button>
|
||||
@endif
|
||||
<button @click="renameFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="p-1.5 text-slate-400 hover:text-amber-600 transition" title="Rename">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/></svg>
|
||||
</button>
|
||||
<button @click="deleteFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-red-600 transition" title="Delete">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">This folder is empty</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Create Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="createModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="createModal = false">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-900" x-text="'Create New ' + (createType === 'folder' ? 'Folder' : 'File')"></h3>
|
||||
<form @submit.prevent="submitCreate">
|
||||
<input type="text" x-model="createName" placeholder="Enter name..." class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="createModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Edit Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="editModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-4xl sm:h-[80vh] h-full sm:rounded-2xl bg-white shadow-xl flex flex-col">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-slate-900" x-text="'Editing: ' + editPath"></h3>
|
||||
<button @click="editModal = false" class="p-1 text-slate-400 hover:text-slate-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="flex-1 p-4"><textarea x-model="editContent" class="w-full h-full font-mono text-sm border border-slate-200 rounded-lg p-4 focus:border-indigo-500 focus:ring-indigo-500 resize-none"></textarea></div>
|
||||
<div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
|
||||
<button @click="editModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button @click="saveEditedFile" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Rename Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="renameModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="renameModal = false">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Rename</h3>
|
||||
<form @submit.prevent="submitRename">
|
||||
<input type="text" x-model="renameName" class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="renameModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Rename</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Move/Copy Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="moveModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="moveModal = false">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-900" x-text="moveAction === 'move' ? 'Move Items' : 'Copy Items'"></h3>
|
||||
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
|
||||
<form @submit.prevent="submitMoveCopy">
|
||||
<label class="mt-4 block text-sm font-medium text-slate-700">Destination path</label>
|
||||
<input type="text" x-model="moveDestination" placeholder="/public_html" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<p class="mt-1 text-xs text-slate-400">Relative to home directory, e.g. /public_html/backup</p>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="moveModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading" x-text="moveAction === 'move' ? 'Move' : 'Copy'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Compress Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="compressModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="compressModal = false">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Compress to Zip</h3>
|
||||
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
|
||||
<form @submit.prevent="submitCompress">
|
||||
<label class="mt-4 block text-sm font-medium text-slate-700">Archive name</label>
|
||||
<input type="text" x-model="archiveName" placeholder="archive.zip" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="compressModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Compress</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Chmod Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="chmodModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="chmodModal = false">
|
||||
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Change Permissions</h3>
|
||||
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
|
||||
<form @submit.prevent="submitChmod">
|
||||
<label class="mt-4 block text-sm font-medium text-slate-700">Permissions (octal)</label>
|
||||
<input type="text" x-model="chmodValue" placeholder="755" maxlength="4" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" @click="chmodValue = '644'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">644</button>
|
||||
<button type="button" @click="chmodValue = '755'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">755</button>
|
||||
<button type="button" @click="chmodValue = '775'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">775</button>
|
||||
<button type="button" @click="chmodValue = '777'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">777</button>
|
||||
<button type="button" @click="chmodValue = '600'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">600</button>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button type="button" @click="chmodModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function fileManager() {
|
||||
return {
|
||||
loading: false,
|
||||
selected: [],
|
||||
allPaths: @json(collect($files)->pluck('path')->values()),
|
||||
toasts: [],
|
||||
toastId: 0,
|
||||
actionStatus: { type: '', title: '', message: '' },
|
||||
// Create
|
||||
createModal: false,
|
||||
createType: 'folder',
|
||||
createName: '',
|
||||
// Edit
|
||||
editModal: false,
|
||||
editPath: '',
|
||||
editContent: '',
|
||||
// Rename
|
||||
renameModal: false,
|
||||
renamePath: '',
|
||||
renameName: '',
|
||||
// Upload
|
||||
uploadProgress: 0,
|
||||
uploadFileName: '',
|
||||
uploading: false,
|
||||
// Move/Copy
|
||||
moveModal: false,
|
||||
moveAction: 'move',
|
||||
moveDestination: '',
|
||||
// Compress
|
||||
compressModal: false,
|
||||
archiveName: 'archive.zip',
|
||||
// Chmod
|
||||
chmodModal: false,
|
||||
chmodValue: '755',
|
||||
// Extract
|
||||
extracting: false,
|
||||
extractFileName: '',
|
||||
|
||||
init() {
|
||||
const stored = sessionStorage.getItem('fileManagerToast');
|
||||
if (!stored) return;
|
||||
|
||||
sessionStorage.removeItem('fileManagerToast');
|
||||
try {
|
||||
const toast = JSON.parse(stored);
|
||||
this.notify(toast.type || 'success', toast.title || 'Done', toast.message || 'Action completed.');
|
||||
} catch (e) {
|
||||
this.notify('success', 'Done', stored);
|
||||
}
|
||||
},
|
||||
|
||||
notify(type, title, message, options = {}) {
|
||||
const toast = {
|
||||
id: ++this.toastId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
this.actionStatus = { type, title, message };
|
||||
|
||||
window.setTimeout(() => this.dismissToast(toast.id), options.duration || 4500);
|
||||
},
|
||||
|
||||
dismissToast(id) {
|
||||
const toast = this.toasts.find(item => item.id === id);
|
||||
if (toast) toast.visible = false;
|
||||
window.setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(item => item.id !== id);
|
||||
}, 200);
|
||||
},
|
||||
|
||||
clearStatus() {
|
||||
this.actionStatus = { type: '', title: '', message: '' };
|
||||
},
|
||||
|
||||
rememberToast(type, title, message) {
|
||||
sessionStorage.setItem('fileManagerToast', JSON.stringify({ type, title, message }));
|
||||
},
|
||||
|
||||
reloadWithToast(type, title, message) {
|
||||
this.rememberToast(type, title, message);
|
||||
location.reload();
|
||||
},
|
||||
|
||||
async parseJsonResponse(res, fallbackMessage) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
throw new Error(data.error || data.message || fallbackMessage || 'Operation failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
errorMessage(error, fallback) {
|
||||
return error?.message || fallback || 'Operation failed';
|
||||
},
|
||||
|
||||
toggleAll(event) {
|
||||
this.selected = event.target.checked ? [...this.allPaths] : [];
|
||||
},
|
||||
|
||||
bulkAction(action) {
|
||||
if (action === 'delete') {
|
||||
if (!confirm('Delete ' + this.selected.length + ' item(s)? This cannot be undone.')) return;
|
||||
this.submitBulkDelete();
|
||||
} else if (action === 'move' || action === 'copy') {
|
||||
this.moveAction = action;
|
||||
this.moveDestination = '{{ $currentPath }}';
|
||||
this.moveModal = true;
|
||||
} else if (action === 'compress') {
|
||||
this.archiveName = 'archive.zip';
|
||||
this.compressModal = true;
|
||||
} else if (action === 'chmod') {
|
||||
this.chmodValue = '755';
|
||||
this.chmodModal = true;
|
||||
}
|
||||
},
|
||||
|
||||
async submitBulkDelete() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.bulk-delete", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ paths: this.selected })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Delete failed');
|
||||
this.reloadWithToast('success', 'Items deleted', data.message || 'Selected items were deleted.');
|
||||
} catch (e) {
|
||||
this.notify('error', 'Delete failed', this.errorMessage(e, 'Delete failed'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async submitMoveCopy() {
|
||||
this.loading = true;
|
||||
const url = this.moveAction === 'move'
|
||||
? '{{ route("hosting.panel.files.bulk-move", $account) }}'
|
||||
: '{{ route("hosting.panel.files.bulk-copy", $account) }}';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ paths: this.selected, destination: this.moveDestination })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Operation failed');
|
||||
this.reloadWithToast('success', this.moveAction === 'move' ? 'Items moved' : 'Items copied', data.message || 'Selected items were updated.');
|
||||
} catch (e) {
|
||||
this.notify('error', 'Operation failed', this.errorMessage(e, 'Operation failed'));
|
||||
} finally { this.loading = false; this.moveModal = false; }
|
||||
},
|
||||
|
||||
async submitCompress() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.bulk-compress", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ paths: this.selected, archive_name: this.archiveName, base_path: '{{ $currentPath }}' })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Compression failed');
|
||||
this.reloadWithToast('success', 'Archive created', data.message || 'Selected items were compressed.');
|
||||
} catch (e) {
|
||||
this.notify('error', 'Compression failed', this.errorMessage(e, 'Compression failed'));
|
||||
} finally { this.loading = false; this.compressModal = false; }
|
||||
},
|
||||
|
||||
async submitChmod() {
|
||||
if (!/^[0-7]{3,4}$/.test(this.chmodValue)) {
|
||||
this.notify('error', 'Invalid permissions', 'Use 3 or 4 octal digits, for example 755.');
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.chmod", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ paths: this.selected, permissions: this.chmodValue })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to change permissions');
|
||||
this.chmodModal = false;
|
||||
this.selected = [];
|
||||
this.notify('success', 'Permissions updated', data.message || 'Selected permissions were updated.');
|
||||
} catch (e) {
|
||||
this.notify('error', 'Permission update failed', this.errorMessage(e, 'Failed to change permissions'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async submitCreate() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.create", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ path: '{{ $currentPath }}', name: this.createName, type: this.createType })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to create');
|
||||
const label = this.createType === 'folder' ? 'Folder created' : 'File created';
|
||||
this.reloadWithToast('success', label, data.message || `${this.createName} was created.`);
|
||||
} catch (e) {
|
||||
this.notify('error', 'Create failed', this.errorMessage(e, 'Failed to create'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async editFile(path) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.read", $account) }}?path=' + encodeURIComponent(path));
|
||||
const data = await this.parseJsonResponse(res, 'Failed to read file');
|
||||
this.editPath = path;
|
||||
this.editContent = data.content;
|
||||
this.editModal = true;
|
||||
} catch (e) {
|
||||
this.notify('error', 'File could not be opened', this.errorMessage(e, 'Failed to read file'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async saveEditedFile() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.save", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ path: this.editPath, content: this.editContent })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to save');
|
||||
this.editModal = false;
|
||||
this.notify('success', 'File saved', data.message || `${this.editPath} was saved.`);
|
||||
} catch (e) {
|
||||
this.notify('error', 'Save failed', this.errorMessage(e, 'Failed to save'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
renameFile(path, name) { this.renamePath = path; this.renameName = name; this.renameModal = true; },
|
||||
|
||||
async submitRename() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.rename", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ path: this.renamePath, newName: this.renameName })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to rename');
|
||||
this.reloadWithToast('success', 'Item renamed', data.message || `${this.renameName} was renamed.`);
|
||||
} catch (e) {
|
||||
this.notify('error', 'Rename failed', this.errorMessage(e, 'Failed to rename'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async deleteFile(path) {
|
||||
if (!confirm('Are you sure you want to delete this?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.delete", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to delete');
|
||||
this.reloadWithToast('success', 'Item deleted', data.message || 'The item was deleted.');
|
||||
} catch (e) {
|
||||
this.notify('error', 'Delete failed', this.errorMessage(e, 'Failed to delete'));
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async extractFile(path, name) {
|
||||
if (!confirm('Extract "' + name + '" in the current directory?\n\nThis may take a while for large archives.')) return;
|
||||
this.loading = true;
|
||||
this.extracting = true;
|
||||
this.extractFileName = name;
|
||||
try {
|
||||
const res = await fetch('{{ route("hosting.panel.files.extract", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
const data = await this.parseJsonResponse(res, 'Failed to extract');
|
||||
const msg = data.total_files
|
||||
? `Extracted ${data.extracted_files || data.total_files} files successfully.`
|
||||
: (data.message || 'Archive extracted successfully.');
|
||||
this.reloadWithToast('success', 'Archive extracted', msg);
|
||||
} catch (e) {
|
||||
this.notify('error', 'Extract failed', this.errorMessage(e, 'Failed to extract'));
|
||||
}
|
||||
finally {
|
||||
this.loading = false;
|
||||
this.extracting = false;
|
||||
this.extractFileName = '';
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
this.loading = true;
|
||||
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
|
||||
const CHUNKED_THRESHOLD = 5 * 1024 * 1024; // Use chunked upload for files > 5MB
|
||||
let completedUploads = 0;
|
||||
let failedUploads = 0;
|
||||
|
||||
for (const file of files) {
|
||||
this.uploading = true;
|
||||
this.uploadFileName = file.name;
|
||||
this.uploadProgress = 0;
|
||||
|
||||
try {
|
||||
if (file.size > CHUNKED_THRESHOLD) {
|
||||
// Chunked upload for large files
|
||||
await this.uploadFileChunked(file, CHUNK_SIZE);
|
||||
} else {
|
||||
// Regular upload for small files
|
||||
await this.uploadFileRegular(file);
|
||||
}
|
||||
completedUploads++;
|
||||
} catch (e) {
|
||||
failedUploads++;
|
||||
console.error('Upload error:', e);
|
||||
this.notify('error', 'Upload failed', e.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
this.loading = false;
|
||||
event.target.value = '';
|
||||
|
||||
if (completedUploads > 0) {
|
||||
const type = failedUploads > 0 ? 'info' : 'success';
|
||||
const title = failedUploads > 0 ? 'Upload partially complete' : 'Upload complete';
|
||||
const message = failedUploads > 0
|
||||
? `${completedUploads} file(s) uploaded, ${failedUploads} failed.`
|
||||
: (files.length === 1 ? `${files[0].name} was uploaded.` : `${files.length} files were uploaded.`);
|
||||
this.reloadWithToast(type, title, message);
|
||||
} else {
|
||||
this.notify('error', 'Upload failed', 'No files were uploaded.');
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFileRegular(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', '{{ $currentPath }}');
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) this.uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (!data.success) {
|
||||
reject(new Error(data.error || 'Failed to upload ' + file.name));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (xhr.status !== 200) {
|
||||
reject(new Error('Upload failed (HTTP ' + xhr.status + ')'));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Upload failed: invalid server response'));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => { reject(new Error('Network error')); });
|
||||
xhr.addEventListener('abort', () => { reject(new Error('Upload cancelled')); });
|
||||
|
||||
xhr.open('POST', '{{ route("hosting.panel.files.upload", $account) }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
async uploadFileChunked(file, chunkSize) {
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
const maxRetries = 3;
|
||||
|
||||
// Step 1: Initialize upload session
|
||||
const initRes = await fetch('{{ route("hosting.panel.files.upload.init", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
filesize: file.size,
|
||||
path: '{{ $currentPath }}'
|
||||
})
|
||||
});
|
||||
const initData = await initRes.json();
|
||||
if (!initData.success) throw new Error(initData.error || 'Failed to initialize upload');
|
||||
|
||||
const uploadId = initData.upload_id;
|
||||
|
||||
// Step 2: Upload chunks with retry logic
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
const expectedSize = end - start;
|
||||
|
||||
let success = false;
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries && !success; attempt++) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('upload_id', uploadId);
|
||||
formData.append('chunk_index', i);
|
||||
formData.append('chunk_size', expectedSize);
|
||||
formData.append('chunk', chunk, `chunk_${i}`);
|
||||
|
||||
const chunkRes = await fetch('{{ route("hosting.panel.files.upload.chunk", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!chunkRes.ok) {
|
||||
throw new Error(`HTTP ${chunkRes.status}`);
|
||||
}
|
||||
|
||||
const chunkData = await chunkRes.json();
|
||||
if (!chunkData.success) {
|
||||
throw new Error(chunkData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new Error(`Failed to upload chunk ${i + 1}/${totalChunks} after ${maxRetries} attempts: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
this.uploadProgress = Math.round(((i + 1) / totalChunks) * 100);
|
||||
}
|
||||
|
||||
// Step 3: Finalize upload
|
||||
const finalRes = await fetch('{{ route("hosting.panel.files.upload.finalize", $account) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({
|
||||
upload_id: uploadId,
|
||||
total_chunks: totalChunks
|
||||
})
|
||||
});
|
||||
const finalData = await finalRes.json();
|
||||
if (!finalData.success) throw new Error(finalData.error || 'Failed to finalize upload');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,156 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Hosting Panel - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Overview - {{ $account->username }}</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
@php
|
||||
$sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning');
|
||||
@endphp
|
||||
|
||||
@if($sslProvisioningSites->isNotEmpty())
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-amber-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-amber-800">SSL Certificate Provisioning</h3>
|
||||
<p class="mt-1 text-sm text-amber-700">
|
||||
SSL certificates are being provisioned for
|
||||
<strong>{{ $sslProvisioningSites->pluck('domain')->join(', ') }}</strong>.
|
||||
Please wait until this completes before installing applications. This usually takes 1-2 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<svg class="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Disk Usage</p>
|
||||
<p class="text-lg font-semibold text-slate-900">{{ number_format(($stats['disk_used_bytes'] ?? 0) / 1048576, 2) }} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Files</p>
|
||||
<p class="text-lg font-semibold text-slate-900">{{ number_format(($stats['file_size_bytes'] ?? 0) / 1048576, 2) }} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@can('manageDatabases', $account)
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg class="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Databases</p>
|
||||
<p class="text-lg font-semibold text-slate-900">{{ $account->databases->count() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
|
||||
@include('components.icons.domain-globe', ['class' => 'h-5 w-5'])
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Domains</p>
|
||||
<p class="text-lg font-semibold text-slate-900">{{ $account->sites->count() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<a href="{{ route('hosting.panel.files', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-indigo-300 hover:shadow-md transition">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-100 group-hover:bg-indigo-200 transition">
|
||||
<svg class="h-6 w-6 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">File Manager</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload, edit, and manage your files</p>
|
||||
</a>
|
||||
|
||||
@can('manageDatabases', $account)
|
||||
<a href="{{ route('hosting.panel.databases', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-purple-300 hover:shadow-md transition">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100 group-hover:bg-purple-200 transition">
|
||||
<svg class="h-6 w-6 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">Databases</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Create and manage MySQL databases</p>
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
<a href="{{ route('hosting.panel.domains', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-amber-300 hover:shadow-md transition">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100 group-hover:bg-amber-200 transition">
|
||||
@include('components.icons.domain-globe', ['class' => 'h-6 w-6 text-amber-600'])
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">Domains</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ auth()->user()->can('manageDomains', $account) ? 'Add and manage addon domains' : 'Review linked domains' }}</p>
|
||||
</a>
|
||||
|
||||
@can('useTerminal', $account)
|
||||
<a href="{{ route('hosting.panel.terminal', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-emerald-300 hover:shadow-md transition">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100 group-hover:bg-emerald-200 transition">
|
||||
<svg class="h-6 w-6 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5 10.5 12l-3.75 4.5m6-9h4.5" /></svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">Terminal</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Run app commands inside your account sandbox</p>
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
@can('viewSettings', $account)
|
||||
<a href="{{ route('hosting.panel.settings', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-slate-400 hover:shadow-md transition">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 group-hover:bg-slate-200 transition">
|
||||
<svg class="h-6 w-6 text-slate-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-slate-900">Settings</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ auth()->user()->can('changePassword', $account) ? 'SFTP password and team access settings' : 'SFTP connection details and your SSH key' }}</p>
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<!-- SFTP Info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">SFTP Access</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Host</p>
|
||||
<p class="mt-1 text-sm font-mono text-slate-900">{{ $account->node?->ip_address ?? '161.97.138.149' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Port</p>
|
||||
<p class="mt-1 text-sm font-mono text-slate-900">22</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Username</p>
|
||||
<p class="mt-1 text-sm font-mono text-slate-900">{{ $account->username }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Password</p>
|
||||
@can('changePassword', $account)
|
||||
<p class="mt-1 text-sm text-slate-500">Set in <a href="{{ route('hosting.panel.settings', $account) }}" class="text-indigo-600 hover:underline">Settings</a></p>
|
||||
@else
|
||||
<p class="mt-1 text-sm text-slate-500">Use your SSH key in <a href="{{ route('hosting.panel.settings', $account) }}" class="text-indigo-600 hover:underline">Settings</a></p>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,80 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Error Logs - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Error Logs</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex rounded-lg border border-slate-200 bg-white p-1">
|
||||
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => 'error', 'lines' => $lines]) }}"
|
||||
class="rounded-md px-4 py-2 text-sm font-medium transition {{ $logType === 'error' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-50' }}">
|
||||
Error Log
|
||||
</a>
|
||||
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => 'access', 'lines' => $lines]) }}"
|
||||
class="rounded-md px-4 py-2 text-sm font-medium transition {{ $logType === 'access' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-50' }}">
|
||||
Access Log
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('hosting.panel.logs', $account) }}" method="GET" class="flex items-center gap-2">
|
||||
<input type="hidden" name="type" value="{{ $logType }}">
|
||||
<select name="lines" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="50" {{ $lines == 50 ? 'selected' : '' }}>Last 50 lines</option>
|
||||
<option value="100" {{ $lines == 100 ? 'selected' : '' }}>Last 100 lines</option>
|
||||
<option value="200" {{ $lines == 200 ? 'selected' : '' }}>Last 200 lines</option>
|
||||
<option value="500" {{ $lines == 500 ? 'selected' : '' }}>Last 500 lines</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('hosting.panel.logs.clear', $account) }}" method="POST" onsubmit="return confirm('Clear this log file?')" class="ml-auto">
|
||||
@csrf
|
||||
<input type="hidden" name="type" value="{{ $logType }}">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Clear Log
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ $logType === 'error' ? 'Error Log' : 'Access Log' }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ $logFilePath }}</p>
|
||||
</div>
|
||||
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => $logType, 'lines' => $lines]) }}" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
Refresh
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-slate-900 p-4 overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono text-slate-300 whitespace-pre-wrap break-all">{{ $logContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-blue-200 bg-blue-50 p-6">
|
||||
<h3 class="text-base font-semibold text-blue-900 mb-2">Log File Locations</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-blue-700">Error Log</p>
|
||||
<code class="text-xs font-mono text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{{ $errorLogPath }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-blue-700">Access Log</p>
|
||||
<code class="text-xs font-mono text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{{ $accessLogPath }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,220 @@
|
||||
@php
|
||||
$currentRoute = request()->route()->getName();
|
||||
$panelRuntime = app(\App\Services\Hosting\PanelRuntimeResolver::class)->forSubject($account);
|
||||
$currentUser = auth()->user();
|
||||
|
||||
$navGroups = [
|
||||
'main' => [
|
||||
[
|
||||
'name' => 'Overview',
|
||||
'route' => route('hosting.panel.index', $account),
|
||||
'active' => $currentRoute === 'hosting.panel.index',
|
||||
'capability' => null,
|
||||
'permission' => 'viewPanel',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />',
|
||||
],
|
||||
[
|
||||
'name' => 'File Manager',
|
||||
'route' => route('hosting.panel.files', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.files'),
|
||||
'capability' => 'files',
|
||||
'permission' => 'manageFiles',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />',
|
||||
],
|
||||
[
|
||||
'name' => 'Databases',
|
||||
'route' => route('hosting.panel.databases', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.databases'),
|
||||
'capability' => 'databases',
|
||||
'permission' => 'manageDatabases',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375" />',
|
||||
],
|
||||
[
|
||||
'name' => 'Domains',
|
||||
'route' => route('hosting.panel.domains', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.domains'),
|
||||
'capability' => 'domains',
|
||||
'permission' => 'viewDomains',
|
||||
'domain_icon' => true,
|
||||
],
|
||||
],
|
||||
'software' => [
|
||||
[
|
||||
'name' => 'Terminal',
|
||||
'route' => route('hosting.panel.terminal', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.terminal'),
|
||||
'capability' => 'terminal',
|
||||
'permission' => 'useTerminal',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5 10.5 12l-3.75 4.5m6-9h4.5" />',
|
||||
],
|
||||
[
|
||||
'name' => 'PHP Settings',
|
||||
'route' => route('hosting.panel.php', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.php'),
|
||||
'capability' => 'php',
|
||||
'permission' => 'managePhp',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />',
|
||||
],
|
||||
[
|
||||
'name' => 'SSL Certificates',
|
||||
'route' => route('hosting.panel.ssl', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.ssl'),
|
||||
'capability' => 'ssl',
|
||||
'permission' => 'manageSsl',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />',
|
||||
],
|
||||
[
|
||||
'name' => 'App Installer',
|
||||
'route' => route('hosting.panel.apps', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.apps'),
|
||||
'capability' => 'apps',
|
||||
'permission' => 'manageApps',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />',
|
||||
],
|
||||
],
|
||||
'advanced' => [
|
||||
[
|
||||
'name' => 'Cron Jobs',
|
||||
'route' => route('hosting.panel.cron', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.cron'),
|
||||
'capability' => 'cron',
|
||||
'permission' => 'manageCron',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
],
|
||||
[
|
||||
'name' => 'Error Logs',
|
||||
'route' => route('hosting.panel.logs', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.logs'),
|
||||
'capability' => 'logs',
|
||||
'permission' => 'viewLogs',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />',
|
||||
],
|
||||
[
|
||||
'name' => 'Settings',
|
||||
'route' => route('hosting.panel.settings', $account),
|
||||
'active' => str_starts_with($currentRoute, 'hosting.panel.settings'),
|
||||
'capability' => 'settings',
|
||||
'permission' => 'viewSettings',
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 010 .255c-.008.378.137.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($navGroups as $group => $items) {
|
||||
$navGroups[$group] = array_values(array_filter($items, static function (array $item) use ($panelRuntime, $currentUser, $account): bool {
|
||||
$capability = $item['capability'] ?? null;
|
||||
$permission = $item['permission'] ?? null;
|
||||
|
||||
return ($capability === null || $panelRuntime->supports($capability))
|
||||
&& ($permission === null || $currentUser?->can($permission, $account));
|
||||
}));
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{{-- Logo/Header --}}
|
||||
<div class="flex h-14 items-center gap-3 border-b border-slate-200 px-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-600">
|
||||
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $panelRuntime->label() }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $account->username }}</p>
|
||||
</div>
|
||||
<button type="button" @click="sidebarOpen = false" class="lg:hidden p-1 text-slate-400 hover:text-slate-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Navigation --}}
|
||||
<nav class="flex-1 overflow-y-auto p-3">
|
||||
{{-- Main Section --}}
|
||||
<ul class="space-y-1">
|
||||
@foreach($navGroups['main'] as $item)
|
||||
<li>
|
||||
<a href="{{ $item['route'] }}"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
@if (! empty($item['domain_icon']))
|
||||
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
|
||||
@else
|
||||
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
{!! $item['icon'] !!}
|
||||
</svg>
|
||||
@endif
|
||||
{{ $item['name'] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
{{-- Software Section --}}
|
||||
@if (! empty($navGroups['software']))
|
||||
<div class="mt-6">
|
||||
<p class="px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Software</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
@foreach($navGroups['software'] as $item)
|
||||
<li>
|
||||
<a href="{{ $item['route'] }}"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
@if (! empty($item['domain_icon']))
|
||||
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
|
||||
@else
|
||||
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
{!! $item['icon'] !!}
|
||||
</svg>
|
||||
@endif
|
||||
{{ $item['name'] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Advanced Section --}}
|
||||
@if (! empty($navGroups['advanced']))
|
||||
<div class="mt-6">
|
||||
<p class="px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Advanced</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
@foreach($navGroups['advanced'] as $item)
|
||||
<li>
|
||||
<a href="{{ $item['route'] }}"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
@if (! empty($item['domain_icon']))
|
||||
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
|
||||
@else
|
||||
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
{!! $item['icon'] !!}
|
||||
</svg>
|
||||
@endif
|
||||
{{ $item['name'] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
{{-- Account Info Footer --}}
|
||||
<div class="border-t border-slate-200 p-3">
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
<span>{{ $account->node->hostname ?? $account->node->ip_address ?? 'Server' }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium {{ $account->status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600' }}">
|
||||
{{ ucfirst($account->status) }}
|
||||
</span>
|
||||
@if($account->product)
|
||||
<span class="text-[10px] text-slate-400">{{ $account->product->name }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">PHP Settings - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">PHP Settings</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">PHP Version</h3>
|
||||
<p class="text-sm text-slate-600 mb-4">Select the PHP version for your hosting account. This will apply to all your domains.</p>
|
||||
<form action="{{ route('hosting.panel.php.version', $account) }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
@foreach($availableVersions as $version)
|
||||
<label for="php_version_{{ str_replace('.', '_', $version) }}" class="relative flex cursor-pointer rounded-lg border p-4 focus:outline-none {{ $currentVersion === $version ? 'border-indigo-600 bg-indigo-50 ring-2 ring-indigo-600' : 'border-slate-200 bg-white hover:bg-slate-50' }}">
|
||||
<input id="php_version_{{ str_replace('.', '_', $version) }}" type="radio" name="php_version" value="{{ $version }}" class="mt-1 h-4 w-4 border-slate-300 text-indigo-600 focus:ring-indigo-500" {{ $currentVersion === $version ? 'checked' : '' }}>
|
||||
<div class="ml-3 flex w-full flex-col items-center">
|
||||
<svg class="h-8 w-8 {{ $currentVersion === $version ? 'text-indigo-600' : 'text-slate-400' }}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<span class="mt-2 text-sm font-semibold {{ $currentVersion === $version ? 'text-indigo-900' : 'text-slate-700' }}">PHP {{ $version }}</span>
|
||||
@if($currentVersion === $version)
|
||||
<span class="mt-1 text-xs text-indigo-600">Current</span>
|
||||
@endif
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
Change PHP Version
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">PHP Configuration</h3>
|
||||
<p class="text-sm text-slate-600 mb-4">Customize PHP settings for your hosting account. These settings are saved to your .user.ini file.</p>
|
||||
<form action="{{ route('hosting.panel.php.settings', $account) }}" method="POST" class="space-y-6">
|
||||
@csrf
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Upload Max Filesize (MB)</label>
|
||||
<input type="number" name="upload_max_filesize" value="{{ $phpSettings['upload_max_filesize'] }}" min="1" max="512" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Maximum size for file uploads (1-512 MB)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Post Max Size (MB)</label>
|
||||
<input type="number" name="post_max_size" value="{{ $phpSettings['post_max_size'] }}" min="1" max="512" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Maximum size for POST data (1-512 MB)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Memory Limit (MB)</label>
|
||||
<input type="number" name="memory_limit" value="{{ $phpSettings['memory_limit'] }}" min="32" max="1024" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Maximum memory per script (32-1024 MB)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Max Execution Time (seconds)</label>
|
||||
<input type="number" name="max_execution_time" value="{{ $phpSettings['max_execution_time'] }}" min="30" max="600" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Maximum script execution time (30-600 seconds)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Max Input Variables</label>
|
||||
<input type="number" name="max_input_vars" value="{{ $phpSettings['max_input_vars'] }}" min="1000" max="10000" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Maximum number of input variables (1000-10000)</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
Save PHP Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-6">
|
||||
<h3 class="text-base font-semibold text-amber-900 mb-2">Custom php.ini</h3>
|
||||
<p class="text-sm text-amber-800 mb-2">You can also edit your .user.ini file directly via the File Manager for additional PHP settings.</p>
|
||||
<p class="text-sm text-amber-800">Location: <code class="bg-amber-100 px-1.5 py-0.5 rounded text-xs font-mono">/public_html/.user.ini</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,160 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Settings - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Settings</x-slot>
|
||||
|
||||
@php
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
$sftpCommand = "sftp {$account->username}@{$serverIp}";
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($canChangePassword)
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-base font-semibold text-slate-900">SFTP Password</h3>
|
||||
<p class="mb-4 text-sm text-slate-600">Change the shared SFTP password for this hosting account. Team developers do not see or manage this password.</p>
|
||||
<form action="{{ route('hosting.panel.settings.password', $account) }}" method="POST" class="max-w-md space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input type="password" name="password" required minlength="8" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||
Change Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">SFTP Key Access</h3>
|
||||
@if ($teamMembership)
|
||||
<p class="mt-2 text-sm text-slate-600">Add your SSH public key to connect over SFTP as <span class="font-mono text-slate-900">{{ $account->username }}</span>. Removing you from the team automatically removes this key.</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-slate-600">Owners connect with the shared account password. Team developers add their own SSH public key here after they sign in.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($teamMembership?->ssh_key_installed_at)
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
Key active
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($teamMembership)
|
||||
<form action="{{ route('hosting.panel.settings.ssh-key', $account) }}" method="POST" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="ssh_public_key" class="mb-1 block text-sm font-medium text-slate-700">SSH Public Key</label>
|
||||
<textarea id="ssh_public_key" name="ssh_public_key" rows="5" required class="w-full rounded-lg border border-slate-200 px-4 py-3 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your@email.com">{{ old('ssh_public_key', $teamMembership->ssh_public_key) }}</textarea>
|
||||
<p class="mt-2 text-xs text-slate-500">Use an OpenSSH public key for SFTP access to this hosting account.</p>
|
||||
<x-input-error :messages="$errors->get('ssh_public_key')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-700">
|
||||
Save SFTP Key
|
||||
</button>
|
||||
|
||||
@if ($teamMembership->ssh_key_installed_at)
|
||||
<p class="text-xs text-slate-500">Last installed {{ $teamMembership->ssh_key_installed_at->diffForHumans() }}.</p>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
@if ($teamMembership->hasSshAccessKey())
|
||||
<form method="POST" action="{{ route('hosting.panel.settings.ssh-key.destroy', $account) }}" class="mt-3" onsubmit="return confirm('Remove your SFTP key access for this hosting account?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center rounded-lg border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:text-slate-900">
|
||||
Remove SFTP Key
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@else
|
||||
<div class="mt-5 rounded-xl bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
Developers on your team use their own SSH keys instead of the shared password, so removing them from the team immediately cuts off SFTP access.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50">
|
||||
<svg class="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m-6 3.75l3 3m0 0l3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-.75"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">SFTP Connection</h3>
|
||||
<p class="text-sm text-slate-500">Connect with any SFTP client</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<code class="flex-1 overflow-x-auto font-mono text-sm text-slate-800">{{ $sftpCommand }}</code>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('{{ $sftpCommand }}'); this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 1500);" class="flex-shrink-0 rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-indigo-700">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-slate-500">Host</p>
|
||||
<p class="mt-1 font-mono text-sm text-slate-900">{{ $serverIp }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-slate-500">Port</p>
|
||||
<p class="mt-1 font-mono text-sm text-slate-900">22</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-slate-500">Username</p>
|
||||
<p class="mt-1 font-mono text-sm text-slate-900">{{ $account->username }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-slate-500">Authentication</p>
|
||||
<p class="mt-1 text-sm text-slate-900">{{ $teamMembership ? 'SSH key' : 'Password' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-base font-semibold text-slate-900">Account Information</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Account Type</p>
|
||||
<p class="mt-1 text-sm text-slate-900">{{ ucfirst($account->type) }} Hosting</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Product</p>
|
||||
<p class="mt-1 text-sm text-slate-900">{{ $account->product->name ?? 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">PHP Version</p>
|
||||
<p class="mt-1 text-sm text-slate-900">{{ $account->php_version ?? '8.2' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500">Status</p>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $account->status === 'active' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-800' }}">
|
||||
{{ ucfirst($account->status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,100 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">SSL Certificates - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">SSL Certificates</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<p class="text-sm text-red-800">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Manage SSL certificates for your domains using Let's Encrypt.</p>
|
||||
</div>
|
||||
@if($account->sites->count())
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">SSL Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($account->sites as $site)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
@include('components.icons.domain-globe', ['class' => 'h-5 w-5 text-slate-400'])
|
||||
<span class="text-sm font-medium text-slate-900">{{ $site->domain }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($site->ssl_enabled)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||
SSL Active
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||
No SSL
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
@if(!$site->ssl_enabled)
|
||||
<form action="{{ route('hosting.panel.ssl.request', [$account, $site]) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
Issue SSL
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-xs text-slate-500">Auto-renews</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
|
||||
<p class="mt-4 text-sm text-slate-500">No domains configured. Add a domain first to enable SSL.</p>
|
||||
<a href="{{ route('hosting.panel.domains', $account) }}" class="mt-4 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">
|
||||
Add Domain
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-blue-200 bg-blue-50 p-6">
|
||||
<h3 class="text-base font-semibold text-blue-900 mb-2">About Let's Encrypt SSL</h3>
|
||||
<ul class="space-y-2 text-sm text-blue-800">
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
<span>Free SSL certificates from Let's Encrypt</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
<span>Certificates auto-renew every 90 days</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
<span>Your domain must point to this server before issuing SSL</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,107 @@
|
||||
<x-hosting-panel-layout :account="$account">
|
||||
<x-slot name="title">Terminal - {{ $account->username }}</x-slot>
|
||||
<x-slot name="header">Terminal</x-slot>
|
||||
|
||||
<div
|
||||
x-data="hostingInteractiveTerminal({
|
||||
accountId: @js($account->getKey()),
|
||||
username: @js($account->username),
|
||||
initialPath: @js($currentPath),
|
||||
csrfToken: @js(csrf_token()),
|
||||
runUrl: @js(route('hosting.panel.terminal.run', $account)),
|
||||
startUrl: @js(route('hosting.panel.terminal.sessions.start', $account)),
|
||||
readUrlTemplate: @js(route('hosting.panel.terminal.sessions.read', [$account, '__SESSION__'])),
|
||||
inputUrlTemplate: @js(route('hosting.panel.terminal.sessions.input', [$account, '__SESSION__'])),
|
||||
resizeUrlTemplate: @js(route('hosting.panel.terminal.sessions.resize', [$account, '__SESSION__'])),
|
||||
closeUrlTemplate: @js(route('hosting.panel.terminal.sessions.close', [$account, '__SESSION__'])),
|
||||
})"
|
||||
class="-m-6 flex h-[calc(100vh-8rem)] flex-col bg-[#0a0a0a]"
|
||||
>
|
||||
{{-- Terminal Header --}}
|
||||
<div class="flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] px-4 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
{{-- macOS-style window controls --}}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="shutdown()" class="h-3 w-3 rounded-full bg-[#ff5f57] transition hover:brightness-110" title="Close"></button>
|
||||
<div class="h-3 w-3 rounded-full bg-[#febc2e]"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-[#28c840]"></div>
|
||||
</div>
|
||||
|
||||
{{-- Session info --}}
|
||||
<div class="flex items-center gap-2 pl-3">
|
||||
<span class="font-mono text-xs text-white/40">{{ $account->username }}@ladill</span>
|
||||
<span class="text-white/20">:</span>
|
||||
<span class="font-mono text-xs text-emerald-400/80" x-text="promptPath()">~</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{{-- Connection status --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="statusLabel === 'Live' ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]' : statusLabel === 'Connecting' ? 'bg-amber-400 animate-pulse' : 'bg-red-400'"
|
||||
></span>
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-white/30" x-text="statusLabel"></span>
|
||||
</div>
|
||||
|
||||
{{-- Reconnect button --}}
|
||||
<button
|
||||
x-show="canReconnect"
|
||||
x-transition
|
||||
@click="restart()"
|
||||
class="flex items-center gap-1.5 rounded-md bg-white/5 px-2.5 py-1 text-[10px] font-medium text-white/60 transition hover:bg-white/10 hover:text-white/80"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Terminal Body --}}
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
x-ref="terminal"
|
||||
@mousedown.prevent="focusTerminal()"
|
||||
@click="focusTerminal()"
|
||||
tabindex="0"
|
||||
class="h-full w-full cursor-text bg-[#0a0a0a] p-2"
|
||||
></div>
|
||||
|
||||
{{-- Connection overlay --}}
|
||||
<div
|
||||
x-show="overlayMessage && !canReconnect"
|
||||
x-transition.opacity.duration.200ms
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#0a0a0a]/90 backdrop-blur-sm"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-3 inline-flex h-8 w-8 items-center justify-center">
|
||||
<svg class="h-5 w-5 animate-spin text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-white/50" x-text="overlayMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Terminal Footer --}}
|
||||
<div class="flex items-center justify-between border-t border-white/5 bg-[#0a0a0a] px-4 py-1.5">
|
||||
<div class="flex items-center gap-4 text-[10px] text-white/25">
|
||||
<span class="font-mono">PTY</span>
|
||||
<span class="font-mono">UTF-8</span>
|
||||
<span class="font-mono">bash</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[10px] text-white/25">
|
||||
<kbd class="rounded bg-white/5 px-1 py-0.5 font-mono">Ctrl+C</kbd>
|
||||
<span>cancel</span>
|
||||
<span class="mx-2 text-white/10">|</span>
|
||||
<kbd class="rounded bg-white/5 px-1 py-0.5 font-mono">Tab</kbd>
|
||||
<span>autocomplete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-hosting-panel-layout>
|
||||
@@ -0,0 +1,183 @@
|
||||
@php
|
||||
$osLabels = [
|
||||
'alma_9' => 'AlmaLinux 9', 'alma_8' => 'AlmaLinux 8',
|
||||
'rocky_9' => 'Rocky Linux 9', 'rocky_8' => 'Rocky Linux 8',
|
||||
'ubuntu_22' => 'Ubuntu 22.04', 'ubuntu_20' => 'Ubuntu 20.04',
|
||||
'ubuntu_18' => 'Ubuntu 18.04', 'ubuntu_16' => 'Ubuntu 16.04',
|
||||
'centos_8' => 'CentOS 8', 'centos_7' => 'CentOS 7',
|
||||
'debian_10' => 'Debian 10', 'debian_9' => 'Debian 9',
|
||||
];
|
||||
$addonLabels = [
|
||||
'cpanel' => 'cPanel',
|
||||
'plesk_10_domain' => 'Plesk 10 Domains',
|
||||
'plesk_30_domain' => 'Plesk 30 Domains',
|
||||
'plesk_unlimited_domain' => 'Plesk Unlimited Domains',
|
||||
'plesk_web_admin_license' => 'Plesk Web Admin',
|
||||
'plesk_web_pro_license' => 'Plesk Web Pro',
|
||||
'plesk_web_host_license' => 'Plesk Web Host',
|
||||
'ipaddress' => 'Dedicated IP Address',
|
||||
'whmcs' => 'WHMCS License',
|
||||
'storage_1' => 'Extra Storage Level 1',
|
||||
'storage_2' => 'Extra Storage Level 2',
|
||||
'storage_3' => 'Extra Storage Level 3',
|
||||
'storage_4' => 'Extra Storage Level 4',
|
||||
'storage_5' => 'Extra Storage Level 5',
|
||||
];
|
||||
$hasAdvanced = $nativeForm['supports_os'] || $nativeForm['supports_addons'] || $nativeForm['supports_ssl_toggle'] || $nativeForm['supports_dedicated_ip'];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@if (session('error'))
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nativeForm['requires_domain'])
|
||||
@php
|
||||
$newDomainUrl = ladill_domains_url('/find');
|
||||
@endphp
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md bg-gray-100 p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
@if ($userDomains->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
<select name="domain_name" :disabled="domainSource !== 'ladill'"
|
||||
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm focus:border-gray-400 focus:ring-0">
|
||||
<option value="">Choose a domain</option>
|
||||
@foreach ($userDomains as $domain)
|
||||
<option value="{{ $domain->host }}" @selected(old('domain_name') === $domain->host)>{{ $domain->host }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="text-xs text-gray-500">
|
||||
Prefer another?
|
||||
<a href="{{ $newDomainUrl }}" class="font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900">Get a new domain</a>
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2.5 text-center text-xs">
|
||||
<a href="{{ $newDomainUrl }}" class="font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900">Get a new domain</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input name="domain_name" type="text" x-model="externalDomain" :disabled="domainSource !== 'external'" value="{{ old('domain_name') }}"
|
||||
placeholder="example.com"
|
||||
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm placeholder-gray-400 focus:border-gray-400 focus:ring-0">
|
||||
</div>
|
||||
@error('domain_name')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">Plan</label>
|
||||
<select name="plan_id" x-model="selectedPlan" @change="ensureMonths()"
|
||||
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm focus:border-gray-400 focus:ring-0">
|
||||
@foreach ($nativeForm['packages'] as $package)
|
||||
<option value="{{ $package['value'] }}">{{ $package['label'] }}{{ ! empty($package['starting_label']) ? ' — From ' . $package['starting_label'] : '' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('plan_id')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<template x-if="selectedPackage() && selectedPackage().summary">
|
||||
<p class="mt-1.5 text-xs text-gray-500" x-text="selectedPackage().summary"></p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="order-months" class="mb-1.5 block text-sm font-medium text-gray-700">Billing</label>
|
||||
<select id="order-months" name="months" x-model="selectedMonths"
|
||||
class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm focus:border-gray-400 focus:ring-0">
|
||||
<template x-for="term in availableTerms()" :key="term">
|
||||
<option :value="term" x-text="termLabel(term)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('months')
|
||||
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="flex flex-col justify-center rounded-lg bg-gray-50 px-3 py-2">
|
||||
<p class="text-[11px] font-medium text-gray-400">Price</p>
|
||||
<p class="text-lg font-semibold text-gray-900">
|
||||
<span x-text="currentPrice() ?? '---'"></span>
|
||||
<span x-show="currentPrice()" class="text-xs font-normal text-gray-400">/ mo</span>
|
||||
</p>
|
||||
<p x-show="currentTotal() && Number(selectedMonths) > 1" class="text-[11px] text-gray-400">
|
||||
<span x-text="currentTotal()"></span> total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($hasAdvanced)
|
||||
<div class="rounded-lg border border-gray-100">
|
||||
<button type="button" @click="showAdvanced = !showAdvanced"
|
||||
class="flex w-full items-center justify-between px-3 py-2.5 text-sm font-medium text-gray-600 transition hover:text-gray-900">
|
||||
<span>Advanced Options</span>
|
||||
<svg class="h-4 w-4 text-gray-400 transition-transform duration-200" :class="showAdvanced ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
|
||||
<div x-show="showAdvanced" x-collapse>
|
||||
<div class="space-y-3 border-t border-gray-100 px-3 pb-3 pt-3">
|
||||
@if ($nativeForm['supports_os'])
|
||||
<div>
|
||||
<label for="order-os" class="mb-1 block text-xs font-medium text-gray-600">Operating System</label>
|
||||
<select id="order-os" name="os" class="w-full rounded-lg border-gray-200 bg-white px-3 py-1.5 text-sm focus:border-gray-400 focus:ring-0">
|
||||
<option value="">Default image</option>
|
||||
@foreach ($nativeForm['os_options'] as $os)
|
||||
<option value="{{ $os }}" @selected(old('os') === $os)>{{ $osLabels[$os] ?? ucwords(str_replace('_', ' ', $os)) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nativeForm['supports_addons'])
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-gray-600">Add-ons</p>
|
||||
<div class="grid gap-1.5 sm:grid-cols-2">
|
||||
@foreach ($nativeForm['addon_options'] as $addon)
|
||||
<label class="flex items-center gap-2 rounded-md bg-gray-50 px-2.5 py-2 text-xs text-gray-700 transition hover:bg-gray-100">
|
||||
<input type="checkbox" name="addons[]" value="{{ $addon }}" @checked(in_array($addon, old('addons', []), true)) class="h-3.5 w-3.5 rounded border-gray-300 text-gray-900 focus:ring-0">
|
||||
<span>{{ $addonLabels[$addon] ?? ucwords(str_replace('_', ' ', $addon)) }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nativeForm['supports_ssl_toggle'] || $nativeForm['supports_dedicated_ip'])
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@if ($nativeForm['supports_ssl_toggle'])
|
||||
<label class="flex items-center gap-2 rounded-md bg-gray-50 px-2.5 py-2 text-xs text-gray-700 transition hover:bg-gray-100">
|
||||
<input type="checkbox" name="enable_ssl" value="1" @checked(old('enable_ssl')) class="h-3.5 w-3.5 rounded border-gray-300 text-gray-900 focus:ring-0">
|
||||
<span>Bundled SSL</span>
|
||||
</label>
|
||||
@endif
|
||||
@if ($nativeForm['supports_dedicated_ip'])
|
||||
<label class="flex items-center gap-2 rounded-md bg-gray-50 px-2.5 py-2 text-xs text-gray-700 transition hover:bg-gray-100">
|
||||
<input type="checkbox" name="add_dedicated_ip" value="1" @checked(old('add_dedicated_ip')) class="h-3.5 w-3.5 rounded border-gray-300 text-gray-900 focus:ring-0">
|
||||
<span>Dedicated IP</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template x-teleport="body">
|
||||
<div x-show="showOrderModal" x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-start sm:justify-center sm:p-4 sm:pt-16"
|
||||
@keydown.escape.window="showOrderModal = false">
|
||||
<div x-show="showOrderModal"
|
||||
x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm hidden sm:block"
|
||||
@click="showOrderModal = false"></div>
|
||||
|
||||
<div x-show="showOrderModal"
|
||||
x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-6xl overflow-hidden sm:rounded-3xl sm:border sm:border-gray-200 bg-white shadow-2xl"
|
||||
@click.stop>
|
||||
<form x-ref="form"
|
||||
method="POST"
|
||||
action="{{ route('hosting.servers.store') }}"
|
||||
x-data="serverOrderForm(@js($serverOrderPayloads), @js($billingCycles), @js($initialServerOrderState))"
|
||||
@submit.prevent="submitOrder"
|
||||
class="max-h-[90vh] overflow-y-auto">
|
||||
@csrf
|
||||
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">New {{ $title }}</h2>
|
||||
<p class="text-sm text-gray-500">Configure the server, review pricing, and add it to your cart.</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="showOrderModal = false"
|
||||
class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-100 bg-gray-50 px-6 py-4">
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
|
||||
<template x-for="(stepInfo, index) in steps" :key="stepInfo.label">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="step > index + 1 ? 'bg-emerald-500 text-white' : (step === index + 1 ? 'bg-lime-300 text-slate-900' : 'bg-gray-200 text-gray-500')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-colors">
|
||||
<template x-if="step > index + 1">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</template>
|
||||
<template x-if="step <= index + 1">
|
||||
<span x-text="index + 1"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span :class="step === index + 1 ? 'text-gray-900' : 'text-gray-500'" class="hidden text-sm font-medium sm:block" x-text="stepInfo.label"></span>
|
||||
<template x-if="index < steps.length - 1">
|
||||
<div class="hidden h-px w-10 bg-gray-300 sm:block"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="product_id" :value="selectedProductId">
|
||||
|
||||
<div class="p-6">
|
||||
<div x-show="step === 1" x-transition>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Select Package</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Choose the server plan that fits your workload.</p>
|
||||
</div>
|
||||
|
||||
@error('product_id')
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($serverOrderPayloads as $productId => $payload)
|
||||
@php
|
||||
$packageSummary = collect($nativeForm['packages'] ?? [])->firstWhere('value', (string) $productId)['summary'] ?? null;
|
||||
@endphp
|
||||
<button type="button"
|
||||
@click="selectProduct('{{ $productId }}')"
|
||||
:class="selectedProductId === '{{ $productId }}' ? 'border-lime-300 ring-2 ring-lime-300/50 bg-lime-50/40' : 'border-gray-200 hover:border-gray-300 hover:shadow-md'"
|
||||
class="group relative flex flex-col rounded-2xl border bg-white p-5 text-left shadow-sm transition-all duration-200">
|
||||
<div class="absolute right-4 top-4">
|
||||
<div :class="selectedProductId === '{{ $productId }}' ? 'border-lime-300 bg-lime-300' : 'border-gray-300'"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors">
|
||||
<svg x-show="selectedProductId === '{{ $productId }}'" class="h-3 w-3 text-slate-900" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="pr-8 text-lg font-bold text-gray-900">{{ $payload['name'] }}</h4>
|
||||
@if ($packageSummary)
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $packageSummary }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-gray-900">{{ strtoupper($payload['currency']) }} {{ number_format($payload['prices']['monthly'] ?? 0, 2) }}</span>
|
||||
<span class="text-sm text-gray-500">/mo</span>
|
||||
</div>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button"
|
||||
@click="nextStep()"
|
||||
:disabled="!selectedProductId"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-gray-900 px-5 py-3 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
Continue
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="step === 2" x-transition>
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_340px]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Server Configuration</h3>
|
||||
<p class="mt-1 text-sm text-gray-500" x-text="selectedProduct?.name || 'Select a package first'"></p>
|
||||
</div>
|
||||
<button type="button" @click="step = 1" class="text-sm font-medium text-gray-500 transition hover:text-gray-700">Change plan</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Server Name</label>
|
||||
<input x-model="serverName" name="server_name" type="text" placeholder="my-server"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
@error('server_name')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Billing Term</label>
|
||||
<select x-model="billingCycle" name="billing_cycle"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="(label, value) in billingCycleLabels" :key="value">
|
||||
<option :value="value" x-text="label"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('billing_cycle')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Region</label>
|
||||
<select x-model="selections.region" name="region"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('regions')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('region')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<h4 class="text-base font-semibold text-gray-900">Image and Access</h4>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Operating System</label>
|
||||
<select x-model="selections.image" name="image" @change="handleImageChange()"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('images')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('image')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Default Login User</label>
|
||||
<select x-model="selections.default_user" name="default_user"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in defaultUserOptions()" :key="option.value">
|
||||
<option :value="option.value" x-text="option.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('default_user')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Control Panel</label>
|
||||
<select x-model="selections.license" name="license"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in availableLicenseOptions()" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('license')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-xl border px-4 py-3 text-sm"
|
||||
:class="currentPanelSnapshot().toneClasses">
|
||||
<p class="font-medium text-current" x-text="currentPanelSnapshot().label"></p>
|
||||
<p class="mt-1 text-current/90" x-text="currentPanelSnapshot().primary"></p>
|
||||
<p class="mt-1 text-xs text-current/80" x-text="currentPanelSnapshot().secondary"></p>
|
||||
<template x-if="currentPanelSnapshot().futureCapabilities.length">
|
||||
<p class="mt-2 text-xs text-current/80">
|
||||
Managed features after provisioning:
|
||||
<span x-text="currentPanelSnapshot().futureCapabilities.join(', ')"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Preinstalled Application</label>
|
||||
<select x-model="selections.application" name="application"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('applications')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('application')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<h4 class="text-base font-semibold text-gray-900">Add-Ons</h4>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Additional IP</label>
|
||||
<select x-model="selections.additional_ip" name="additional_ip"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('additional_ip')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('additional_ip')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Private Networking</label>
|
||||
<select x-model="selections.private_networking" name="private_networking"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('private_networking')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('private_networking')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Storage Type</label>
|
||||
<select x-model="selections.storage_type" name="storage_type"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('storage_types')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('storage_type')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Object Storage</label>
|
||||
<select x-model="selections.object_storage" name="object_storage"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
<template x-for="option in catalog('object_storage')" :key="option.value">
|
||||
<option :value="option.value" x-text="optionLabel(option)"></option>
|
||||
</template>
|
||||
</select>
|
||||
@error('object_storage')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:sticky lg:top-6 lg:self-start">
|
||||
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<h4 class="font-semibold text-gray-900">Order Summary</h4>
|
||||
<p class="text-xs text-gray-500" x-text="selectedProduct?.name || 'Select a plan'"></p>
|
||||
</div>
|
||||
<div class="space-y-3 p-5">
|
||||
<div class="flex items-start justify-between gap-3 text-sm">
|
||||
<span class="text-gray-600">Base Plan</span>
|
||||
<span class="font-medium text-gray-900" x-text="formatMoney(basePrice())"></span>
|
||||
</div>
|
||||
<template x-for="item in addOnLineItems()" :key="item.key">
|
||||
<div class="flex items-start justify-between gap-3 text-sm">
|
||||
<span class="text-gray-600" x-text="item.label"></span>
|
||||
<span class="font-medium text-gray-900" x-text="'+' + formatMoney(item.amount)"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="rounded-xl bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Total</span>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="formatMoney(currentTotal())"></p>
|
||||
<p class="text-xs text-gray-500" x-text="`${cycleMonths()} month term`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="button" @click="step = 1" class="flex-1 rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50">Back</button>
|
||||
<button type="button"
|
||||
@click="nextStep()"
|
||||
:disabled="!serverName.trim()"
|
||||
class="flex-1 rounded-xl bg-gray-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="step === 3" x-transition>
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_340px]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Server Login Credentials</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Set a secure password for the initial server access.</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Password</label>
|
||||
<input x-model="serverPassword" name="server_password" type="password"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
@error('server_password')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Confirm Password</label>
|
||||
<input x-model="serverPasswordConfirmation" name="server_password_confirmation" type="password"
|
||||
class="w-full rounded-xl border-gray-200 bg-white px-4 py-3 text-sm shadow-sm focus:border-lime-300 focus:ring-lime-300">
|
||||
@error('server_password_confirmation')
|
||||
<p class="mt-1.5 text-xs text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-xl bg-amber-50 px-4 py-4 text-sm text-amber-800">
|
||||
<p class="font-medium">Password requirements</p>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs text-amber-700">
|
||||
<li>At least 8 characters</li>
|
||||
<li>Include uppercase and lowercase letters</li>
|
||||
<li>Include either 3 digits and 1 symbol, or 1 digit and 2 symbols</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<h4 class="text-base font-semibold text-gray-900">Configuration Summary</h4>
|
||||
<div class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-gray-500">Server Name</span>
|
||||
<span class="text-right font-medium text-gray-900" x-text="serverName"></span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-gray-500">Image</span>
|
||||
<span class="text-right font-medium text-gray-900" x-text="selectionLabel('images', selections.image)"></span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-gray-500">Region</span>
|
||||
<span class="text-right font-medium text-gray-900" x-text="selectionLabel('regions', selections.region)"></span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-gray-500">Login User</span>
|
||||
<span class="text-right font-medium text-gray-900" x-text="selections.default_user"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:sticky lg:top-6 lg:self-start">
|
||||
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<h4 class="font-semibold text-gray-900">Complete Order</h4>
|
||||
<p class="text-xs text-gray-500">Review and add to cart</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="rounded-xl bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total Due</p>
|
||||
<p class="text-2xl font-bold text-gray-900" x-text="formatMoney(currentTotal())"></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-500" x-text="`${cycleMonths()} month term`"></p>
|
||||
<p class="text-sm text-gray-500" x-text="`${formatMoney(monthlyEquivalent())}/mo`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="error">
|
||||
<p class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" x-text="error"></p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button type="submit"
|
||||
:disabled="submitting || !canSubmit()"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-lime-300 px-5 py-4 text-base font-bold text-slate-900 transition hover:bg-lime-200 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<template x-if="submitting">
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</template>
|
||||
<svg x-show="!submitting" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
|
||||
Add to Cart
|
||||
</button>
|
||||
|
||||
<button type="button" @click="step = 2" class="w-full rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50">Back to Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="title">{{ $title }}</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Choose Your Hosting Plan</h1>
|
||||
<p class="mt-2 text-slate-600">Select the hosting type that best fits your needs</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<!-- Single Domain -->
|
||||
<a href="{{ route('hosting.single-domain') }}"
|
||||
class="group relative rounded-2xl border border-slate-200 bg-white p-6 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600 group-hover:bg-indigo-200 transition">
|
||||
@include('components.icons.domain-globe', ['class' => 'h-7 w-7'])
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-slate-900 group-hover:text-indigo-600 transition">Single Domain</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Perfect for hosting one website. Ideal for personal sites, portfolios, or small businesses.</p>
|
||||
<div class="mt-4 flex items-center text-sm font-medium text-indigo-600">
|
||||
<span>Get Started</span>
|
||||
<svg class="ml-1 h-4 w-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Multi Domain -->
|
||||
<a href="{{ route('hosting.multi-domain') }}"
|
||||
class="group relative rounded-2xl border border-slate-200 bg-white p-6 hover:border-emerald-300 hover:shadow-lg transition-all duration-200">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-xl bg-emerald-100 group-hover:bg-emerald-200 transition">
|
||||
<svg class="h-7 w-7 text-emerald-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-slate-900 group-hover:text-emerald-600 transition">Multi Domain</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Host multiple websites on one account. Great for agencies, developers, or managing several projects.</p>
|
||||
<div class="mt-4 flex items-center text-sm font-medium text-emerald-600">
|
||||
<span>Get Started</span>
|
||||
<svg class="ml-1 h-4 w-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- WordPress -->
|
||||
<a href="{{ route('hosting.wordpress') }}"
|
||||
class="group relative rounded-2xl border border-slate-200 bg-white p-6 hover:border-sky-300 hover:shadow-lg transition-all duration-200">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-xl bg-sky-100 group-hover:bg-sky-200 transition">
|
||||
@include('partials.wordpress-icon', ['class' => 'h-8 w-8'])
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-slate-900 group-hover:text-sky-600 transition">WordPress</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Optimized for WordPress with one-click install, automatic updates, and enhanced security.</p>
|
||||
<div class="mt-4 flex items-center text-sm font-medium text-sky-600">
|
||||
<span>Get Started</span>
|
||||
<svg class="ml-1 h-4 w-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,304 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $order->domain_name }} - Server Details</x-slot>
|
||||
|
||||
@php
|
||||
$statusColors = [
|
||||
'pending_payment' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'pending_approval' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'approved' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'active' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'suspended' => 'bg-red-50 text-red-700 border-red-200',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
'expired' => 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
'failed' => 'bg-red-50 text-red-700 border-red-200',
|
||||
];
|
||||
|
||||
$statusLabels = collect([
|
||||
'pending_payment', 'pending_approval', 'approved', 'provisioning',
|
||||
'active', 'suspended', 'cancelled', 'expired', 'failed',
|
||||
])->mapWithKeys(fn (string $status) => [
|
||||
$status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status),
|
||||
])->all();
|
||||
$statusColors['approved'] = 'bg-amber-50 text-amber-700 border-amber-200';
|
||||
|
||||
$backRoute = $order->product?->type === 'dedicated' ? route('hosting.dedicated') : route('hosting.vps');
|
||||
$backLabel = $order->product?->type === 'dedicated' ? 'Dedicated Servers' : 'VPS';
|
||||
$serverOrder = (array) ($order->meta['server_order'] ?? []);
|
||||
$selectionSummary = (array) ($serverOrder['selection_summary'] ?? []);
|
||||
$quote = (array) ($serverOrder['quote'] ?? []);
|
||||
$panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($serverOrder, 'panel_snapshot', []));
|
||||
$quoteItems = collect((array) ($quote['line_items'] ?? []))
|
||||
->filter(fn ($item) => ($item['code'] ?? null) !== 'base_plan')
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{ showCancelModal: false }">
|
||||
{{-- Page Header --}}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<a href="{{ $backRoute }}" class="mb-2 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
Back to {{ $backLabel }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $order->domain_name }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $order->product?->name ?? 'Server Order' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-lg border px-3 py-1.5 text-sm font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500 border-gray-200' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
{{-- Main Content --}}
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
{{-- Server Details --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Server Details</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Server Name</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->domain_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Plan</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->product?->name ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Region</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $order->meta['region'] ?? 'EU' }}</span>
|
||||
</div>
|
||||
@if ($order->server_ip)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">IP Address</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->server_ip }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Billing Cycle</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ str($order->billing_cycle)->replace('_', ' ')->title() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Amount</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ strtoupper($order->currency) }} {{ number_format($order->amount_paid, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($panelRuntime !== [])
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Panel Mode</h2>
|
||||
</div>
|
||||
<div class="space-y-3 px-5 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $panelRuntime['label'] ?? 'Server Access' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600">{{ $panelRuntime['primary_message'] ?? '' }}</p>
|
||||
@if (! empty($panelRuntime['secondary_message']))
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $panelRuntime['secondary_message'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (! empty($panelRuntime['future_capabilities']))
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Managed features after provisioning: {{ implode(', ', (array) $panelRuntime['future_capabilities']) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Server Resources --}}
|
||||
@if ($order->product)
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Server Resources</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 p-5 sm:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->cpu_cores }}</p>
|
||||
<p class="text-xs text-gray-500">vCPU Cores</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->formatRam() }}</p>
|
||||
<p class="text-xs text-gray-500">RAM</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->formatDiskSize() }}</p>
|
||||
<p class="text-xs text-gray-500">SSD Storage</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $order->product->formatBandwidth() }}</p>
|
||||
<p class="text-xs text-gray-500">Bandwidth</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($selectionSummary !== [])
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Selected Options</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($selectionSummary as $item)
|
||||
<div class="flex items-start justify-between gap-4 px-5 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ $item['label'] ?? 'Option' }}</p>
|
||||
@if (!empty($item['description']))
|
||||
<p class="mt-1 text-xs text-gray-400">{{ $item['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $item['value'] ?? '-' }}</p>
|
||||
@if (!empty($item['monthly_amount']))
|
||||
<p class="mt-1 text-xs text-gray-500">+ {{ strtoupper($order->currency) }} {{ number_format((float) $item['monthly_amount'], 2) }}/mo before term scaling</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($quoteItems->isNotEmpty())
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Pricing Breakdown</h2>
|
||||
</div>
|
||||
<div class="space-y-3 p-5">
|
||||
@foreach ($quoteItems as $item)
|
||||
<div class="flex items-start justify-between gap-4 text-sm">
|
||||
<p class="font-medium text-gray-900">{{ $item['label'] ?? 'Add-on' }}</p>
|
||||
<span class="font-medium text-gray-900">{{ strtoupper($order->currency) }} {{ number_format((float) ($item['amount'] ?? 0), 2) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
<div class="flex items-center justify-between border-t border-gray-100 pt-3 text-sm font-semibold text-gray-900">
|
||||
<span>Total Charged</span>
|
||||
<span>{{ strtoupper($order->currency) }} {{ number_format((float) $order->amount_paid, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- SSH Access (if active) --}}
|
||||
@if ($order->isActive() && $order->server_ip)
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">SSH Access</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="rounded-lg bg-gray-900 p-4">
|
||||
<code class="text-sm text-green-400">ssh root@{{ $order->server_ip }}</code>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Use the credentials sent to your email to connect.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Quick Actions --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Quick Actions</h2>
|
||||
</div>
|
||||
<div class="space-y-2 p-5">
|
||||
@if ($order->control_panel_url && $order->isActive())
|
||||
<a href="{{ $order->control_panel_url }}" target="_blank"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
Open Server Manager
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ ladill_account_url('/support-tickets/create') }}"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/></svg>
|
||||
Get Support
|
||||
</a>
|
||||
@if ($order->canBeCancelled())
|
||||
<button @click="showCancelModal = true" type="button"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
Cancel Order
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Status Timeline --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Status</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg class="h-3 w-3 text-emerald-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Order Placed</p>
|
||||
<p class="text-xs text-gray-500">{{ $order->created_at->format('M d, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full {{ $order->approved_at ? 'bg-emerald-100' : 'bg-gray-100' }}">
|
||||
@if ($order->approved_at)
|
||||
<svg class="h-3 w-3 text-emerald-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
@else
|
||||
<div class="h-2 w-2 rounded-full bg-gray-300"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium {{ $order->approved_at ? 'text-gray-900' : 'text-gray-400' }}">Approved</p>
|
||||
@if ($order->approved_at)
|
||||
<p class="text-xs text-gray-500">{{ $order->approved_at->format('M d, Y H:i') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full {{ $order->provisioned_at ? 'bg-emerald-100' : 'bg-gray-100' }}">
|
||||
@if ($order->provisioned_at)
|
||||
<svg class="h-3 w-3 text-emerald-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L8 12.586l7.293-7.293a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
|
||||
@else
|
||||
<div class="h-2 w-2 rounded-full bg-gray-300"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium {{ $order->provisioned_at ? 'text-gray-900' : 'text-gray-400' }}">Provisioned</p>
|
||||
@if ($order->provisioned_at)
|
||||
<p class="text-xs text-gray-500">{{ $order->provisioned_at->format('M d, Y H:i') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cancel Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="showCancelModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="showCancelModal = false">
|
||||
<div x-show="showCancelModal" x-transition class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm hidden sm:block" @click="showCancelModal = false"></div>
|
||||
<div x-show="showCancelModal" x-transition class="relative min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-xl sm:border sm:border-gray-200 bg-white p-6 shadow-xl" @click.stop>
|
||||
<button @click="showCancelModal = false" type="button" class="absolute top-3 right-3 z-10 flex h-8 w-8 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 sm:hidden"><svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg></button>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Cancel Order</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">Are you sure you want to cancel this server order? This action cannot be undone.</p>
|
||||
<form action="{{ route('servers.orders.cancel', $order) }}" method="POST" class="mt-4">
|
||||
@csrf
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button @click="showCancelModal = false" type="button" class="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">Keep Order</button>
|
||||
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">Cancel Order</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,498 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $order->domain_name }} - Ladill Server Manager</x-slot>
|
||||
|
||||
@php
|
||||
$powerStatus = strtolower((string) ($panelState['power_status'] ?? 'unknown'));
|
||||
$instanceStatus = strtolower((string) ($panelState['instance_status'] ?? 'unknown'));
|
||||
$badgeClasses = [
|
||||
'on' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'off' => 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'running' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'stopped' => 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'unknown' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'online' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'offline' => 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'pending_registration' => 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'queued' => 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
'dispatched' => 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'completed' => 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
'failed' => 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
];
|
||||
$panelRuntime = $panelRuntime ?? [];
|
||||
$agentStatus = strtolower((string) ($serverAgent?->status ?? 'pending_registration'));
|
||||
$managedTasksEnabled = (bool) ($panelRuntime['managed_stack_candidate'] ?? false);
|
||||
$taskTypeLabels = [
|
||||
'domain.add' => 'Add Domain',
|
||||
'site.delete' => 'Remove Site',
|
||||
'ssl.request' => 'Request SSL',
|
||||
'database.create' => 'Create Database',
|
||||
'php.configure' => 'Configure PHP',
|
||||
'cron.list' => 'List Cron Jobs',
|
||||
'cron.upsert' => 'Save Cron Job',
|
||||
'cron.remove' => 'Remove Cron Job',
|
||||
'log.read' => 'Read Logs',
|
||||
'file.list' => 'List Files',
|
||||
'file.read' => 'Read File',
|
||||
'file.write' => 'Write File',
|
||||
];
|
||||
$cronEntries = (array) data_get($order->meta, 'server_panel.managed_cron_entries', []);
|
||||
$logSnapshots = collect((array) data_get($order->meta, 'server_panel.log_snapshots', []))
|
||||
->sortByDesc('read_at')
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<a href="{{ route('servers.orders.show', $order) }}" class="mb-2 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
Back to server details
|
||||
</a>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Ladill Server Manager</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $order->domain_name }} · {{ $order->product?->name ?? 'Server' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('hosting.server-panel.sync', $order) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Refresh Status
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-xs font-medium text-gray-500">Power</p>
|
||||
<span class="mt-2 inline-flex items-center rounded-lg border px-3 py-1.5 text-sm font-medium {{ $badgeClasses[$powerStatus] ?? $badgeClasses['unknown'] }}">
|
||||
{{ strtoupper($powerStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-xs font-medium text-gray-500">Instance Status</p>
|
||||
<span class="mt-2 inline-flex items-center rounded-lg border px-3 py-1.5 text-sm font-medium {{ $badgeClasses[$instanceStatus] ?? $badgeClasses['unknown'] }}">
|
||||
{{ strtoupper($instanceStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-xs font-medium text-gray-500">IPv4 Address</p>
|
||||
<p class="mt-2 font-mono text-sm font-semibold text-gray-900">{{ $order->server_ip ?: '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-xs font-medium text-gray-500">Last Sync</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900">
|
||||
{{ data_get($panelState, 'last_synced_at') ? \Illuminate\Support\Carbon::parse(data_get($panelState, 'last_synced_at'))->format('M d, Y H:i') : 'Not synced yet' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
@if ($panelRuntime !== [])
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Panel Mode</h2>
|
||||
</div>
|
||||
<div class="space-y-3 px-5 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $panelRuntime['label'] ?? 'Server Manager' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600">{{ $panelRuntime['primary_message'] ?? '' }}</p>
|
||||
@if (!empty($panelRuntime['secondary_message']))
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $panelRuntime['secondary_message'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty($panelRuntime['future_capabilities']))
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Managed features: {{ implode(', ', (array) $panelRuntime['future_capabilities']) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Server Controls</h2>
|
||||
</div>
|
||||
<div class="grid gap-3 p-5 sm:grid-cols-3">
|
||||
<form method="POST" action="{{ route('hosting.server-panel.power', [$order, 'start']) }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-emerald-700">
|
||||
Start Server
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('hosting.server-panel.power', [$order, 'reboot']) }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Reboot Server
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('hosting.server-panel.power', [$order, 'stop']) }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800">
|
||||
Stop Server
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Selected Configuration</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($selectionSummary as $item)
|
||||
<div class="flex items-start justify-between gap-4 px-5 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ $item['label'] ?? 'Option' }}</p>
|
||||
@if (!empty($item['description']))
|
||||
<p class="mt-1 text-xs text-gray-400">{{ $item['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-right text-sm font-medium text-gray-900">{{ $item['value'] ?? '-' }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($managedTasksEnabled)
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Managed Tasks</h2>
|
||||
</div>
|
||||
<div class="space-y-6 p-5">
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.domain', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue Domain Link</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Create or update a site/vhost on the managed server stack.</p>
|
||||
</div>
|
||||
<input type="text" name="domain" placeholder="example.com" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<input type="text" name="document_root" placeholder="/var/www/example.com/public" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<select name="php_version" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="">Default PHP Version</option>
|
||||
<option value="8.0">PHP 8.0</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.3">PHP 8.3</option>
|
||||
<option value="8.4">PHP 8.4</option>
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue Domain Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.database', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue Database Create</p>
|
||||
<p class="mt-1 text-xs text-gray-500">The server agent will provision the database on the managed stack.</p>
|
||||
</div>
|
||||
<input type="text" name="name" placeholder="app_db" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue Database Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.ssl', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue SSL Request</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Issue or renew a certificate for a managed server domain.</p>
|
||||
</div>
|
||||
<input type="text" name="domain" placeholder="example.com" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue SSL Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.file', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue File Task</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Use this for file listing, reads, or writes through the server agent.</p>
|
||||
</div>
|
||||
<select name="operation" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="list">List Path</option>
|
||||
<option value="read">Read File</option>
|
||||
<option value="write">Write File</option>
|
||||
</select>
|
||||
<input type="text" name="path" placeholder="/var/www/example.com/public/index.php" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<textarea name="content" rows="4" placeholder="Optional file contents for write operations" class="w-full rounded-lg border-gray-200 text-sm"></textarea>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue File Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.php', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue PHP Version Change</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Switch the nginx vhost for a known site to a different PHP-FPM version.</p>
|
||||
</div>
|
||||
<input type="text" name="domain" placeholder="example.com" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<select name="php_version" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="8.0">PHP 8.0</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.3">PHP 8.3</option>
|
||||
<option value="8.4">PHP 8.4</option>
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue PHP Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.site-delete', $order) }}" class="space-y-3 rounded-lg border border-rose-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue Site Removal</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Remove the nginx site configuration for a managed domain. Website files are left on disk.</p>
|
||||
</div>
|
||||
<input type="text" name="domain" placeholder="example.com" class="w-full rounded-lg border-gray-200 text-sm" required>
|
||||
<button type="submit" class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700">Queue Removal Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.cron', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue Cron Task</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Manage the Ladill-owned cron file on this server. Use a standard 5-field cron expression.</p>
|
||||
</div>
|
||||
<select name="operation" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="list">List Cron Jobs</option>
|
||||
<option value="upsert">Create or Update Job</option>
|
||||
<option value="remove">Remove Job</option>
|
||||
</select>
|
||||
<input type="text" name="name" placeholder="nightly-backup" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<input type="text" name="expression" placeholder="0 2 * * *" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<input type="text" name="command" placeholder="cd /var/www/example.com && php artisan schedule:run" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<select name="user" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="web">Run as web user</option>
|
||||
<option value="root">Run as root</option>
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue Cron Task</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.server-panel.tasks.log', $order) }}" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
@csrf
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Queue Log Read</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Tail domain-specific nginx logs or service journals directly from the managed server.</p>
|
||||
</div>
|
||||
<select name="target" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<option value="nginx_access">Nginx Access Log</option>
|
||||
<option value="nginx_error">Nginx Error Log</option>
|
||||
<option value="php_fpm">PHP-FPM Journal</option>
|
||||
<option value="mariadb">MariaDB Journal</option>
|
||||
<option value="agent_service">Ladill Agent Journal</option>
|
||||
</select>
|
||||
<input type="text" name="domain" placeholder="example.com for nginx logs" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<input type="number" name="lines" min="20" max="500" value="120" class="w-full rounded-lg border-gray-200 text-sm">
|
||||
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800">Queue Log Task</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">Recent Tasks</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($serverTasks as $task)
|
||||
<div class="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $taskTypeLabels[$task->type] ?? $task->type }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ json_encode($task->payload ?? [], JSON_UNESCAPED_SLASHES) }}</p>
|
||||
@if ($task->error_message)
|
||||
<p class="mt-2 text-xs text-rose-600">{{ $task->error_message }}</p>
|
||||
@elseif (($task->result['message'] ?? null))
|
||||
<p class="mt-2 text-xs text-gray-500">{{ $task->result['message'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-left sm:text-right">
|
||||
<span class="inline-flex items-center rounded-lg border px-3 py-1 text-xs font-medium {{ $badgeClasses[strtolower((string) $task->status)] ?? $badgeClasses['unknown'] }}">
|
||||
{{ strtoupper((string) $task->status) }}
|
||||
</span>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ $task->updated_at?->format('M d, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-6 text-sm text-gray-500">No managed tasks queued yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">Known Sites</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($serverSites as $site)
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $site->domain }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $site->document_root ?: 'Document root pending' }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
PHP {{ $site->php_version ?: 'default' }} · SSL {{ strtoupper((string) ($site->ssl_status ?: 'pending')) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs {{ $site->status === 'removed' ? 'text-rose-500' : 'text-gray-400' }}">
|
||||
{{ strtoupper((string) $site->status) }}
|
||||
</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-5 text-sm text-gray-500">No managed sites recorded yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">Known Databases</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($serverDatabases as $database)
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $database->name }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $database->username ?: 'Username pending' }} · {{ strtoupper((string) $database->status) }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ strtoupper((string) $database->engine) }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-5 text-sm text-gray-500">No managed databases recorded yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">File Sync Records</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($serverFiles as $file)
|
||||
<div class="px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-gray-900">{{ $file->path }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ ucfirst($file->kind) }} · {{ $file->size_bytes !== null ? number_format($file->size_bytes) . ' bytes' : 'Size pending' }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ $file->last_synced_at?->format('M d, Y H:i') ?: 'Not synced yet' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-5 text-sm text-gray-500">No managed file records yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">Managed Cron Entries</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($cronEntries as $entry)
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $entry['name'] ?? 'cron-job' }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $entry['schedule'] ?? '-' }} · {{ $entry['user'] ?? 'web' }}</p>
|
||||
<p class="mt-1 truncate text-xs text-gray-400">{{ $entry['command'] ?? '-' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-5 text-sm text-gray-500">No managed cron entries synced yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<h3 class="text-sm font-medium text-gray-900">Recent Log Snapshots</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse ($logSnapshots as $snapshot)
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ strtoupper(str_replace('_', ' ', (string) ($snapshot['target'] ?? 'log'))) }}
|
||||
@if (!empty($snapshot['domain']))
|
||||
<span class="text-gray-400">· {{ $snapshot['domain'] }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $snapshot['read_at'] ? \Illuminate\Support\Carbon::parse($snapshot['read_at'])->format('M d, Y H:i') : 'Pending' }}</p>
|
||||
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-gray-950 p-3 text-[11px] leading-5 text-green-300">{{ $snapshot['content_preview'] ?? 'No preview available yet.' }}</pre>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-5 text-sm text-gray-500">No log snapshots captured yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if ($managedTasksEnabled)
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Agent Status</h2>
|
||||
</div>
|
||||
<div class="space-y-3 px-5 py-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-500">Status</span>
|
||||
<span class="inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium {{ $badgeClasses[$agentStatus] ?? $badgeClasses['unknown'] }}">
|
||||
{{ strtoupper($agentStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-500">Hostname</span>
|
||||
<span class="text-right text-sm font-medium text-gray-900">{{ $serverAgent?->hostname ?: 'Not registered yet' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-500">Version</span>
|
||||
<span class="text-right text-sm font-medium text-gray-900">{{ $serverAgent?->agent_version ?: '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-500">Last Heartbeat</span>
|
||||
<span class="text-right text-sm font-medium text-gray-900">
|
||||
{{ $serverAgent?->last_heartbeat_at ? $serverAgent->last_heartbeat_at->format('M d, Y H:i') : 'Waiting for registration' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (! empty($serverAgent?->capabilities))
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Reported Capabilities</p>
|
||||
<p class="mt-1 text-xs text-gray-700">{{ implode(', ', (array) $serverAgent->capabilities) }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
The agent registration and heartbeat pipeline is now active. Task execution still depends on the server-side agent binary being installed on the machine.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Instance Details</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Region</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ data_get($panelState, 'region', $order->meta['region'] ?? '-') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Datacenter</span>
|
||||
<span class="text-sm font-medium text-gray-900">{{ data_get($panelState, 'datacenter', '-') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Image</span>
|
||||
<span class="text-right text-sm font-medium text-gray-900">{{ data_get($panelState, 'image', '-') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Username</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->server_username ?: 'root' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Access</h2>
|
||||
<div class="mt-3 space-y-2 text-sm text-gray-600">
|
||||
<p>Use the server password you set at checkout to access the machine.</p>
|
||||
@if ($order->server_ip)
|
||||
<div class="rounded-lg bg-gray-900 p-3">
|
||||
<code class="text-xs text-green-400">ssh {{ $order->server_username ?: 'root' }}@{{ $order->server_ip }}</code>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,225 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $title }}</x-slot>
|
||||
|
||||
@php
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$orderFormErrorFields = [
|
||||
'product_id',
|
||||
'server_name',
|
||||
'billing_cycle',
|
||||
'region',
|
||||
'image',
|
||||
'license',
|
||||
'application',
|
||||
'default_user',
|
||||
'additional_ip',
|
||||
'private_networking',
|
||||
'storage_type',
|
||||
'object_storage',
|
||||
'server_password',
|
||||
'server_password_confirmation',
|
||||
];
|
||||
$hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field));
|
||||
$shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors;
|
||||
|
||||
$initialServerStep = old('product_id')
|
||||
? (($errors->has('server_password') || $errors->has('server_password_confirmation')) ? 3 : 2)
|
||||
: 1;
|
||||
|
||||
$initialServerOrderState = [
|
||||
'selectedProductId' => old('product_id', ''),
|
||||
'serverName' => old('server_name', ''),
|
||||
'billingCycle' => old('billing_cycle', \App\Models\CustomerHostingOrder::CYCLE_MONTHLY),
|
||||
'step' => $initialServerStep,
|
||||
'selections' => [
|
||||
'region' => old('region', ''),
|
||||
'image' => old('image', ''),
|
||||
'license' => old('license', ''),
|
||||
'application' => old('application', ''),
|
||||
'default_user' => old('default_user', ''),
|
||||
'additional_ip' => old('additional_ip', ''),
|
||||
'private_networking' => old('private_networking', ''),
|
||||
'storage_type' => old('storage_type', ''),
|
||||
'object_storage' => old('object_storage', ''),
|
||||
],
|
||||
];
|
||||
|
||||
$statusColors = [
|
||||
'pending_payment' => 'bg-amber-50 text-amber-700',
|
||||
'pending_approval' => 'bg-amber-50 text-amber-700',
|
||||
'approved' => 'bg-blue-50 text-blue-700',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700',
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500',
|
||||
'expired' => 'bg-gray-100 text-gray-500',
|
||||
'failed' => 'bg-red-50 text-red-700',
|
||||
];
|
||||
|
||||
$statusLabels = collect([
|
||||
'pending_payment', 'pending_approval', 'approved', 'provisioning',
|
||||
'active', 'suspended', 'cancelled', 'expired', 'failed',
|
||||
])->mapWithKeys(fn (string $status) => [
|
||||
$status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status),
|
||||
])->all();
|
||||
$statusColors['approved'] = 'bg-amber-50 text-amber-700';
|
||||
|
||||
$formatMoney = static fn (?float $amount, string $currency = 'GHS'): string => $amount === null
|
||||
? '-'
|
||||
: strtoupper($currency).' '.number_format($amount, 2);
|
||||
|
||||
$hasAnyProducts = $orders->isNotEmpty() || $rcServiceOrders->isNotEmpty();
|
||||
|
||||
$emptyStateIcon = $type === 'vps'
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7"/>'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z"/>';
|
||||
|
||||
$emptyStateTitle = $type === 'vps' ? 'No VPS instances' : 'No dedicated servers';
|
||||
$emptyStateDescription = $type === 'vps'
|
||||
? 'You don\'t have any VPS instances yet. Deploy a virtual private server to get started with scalable cloud infrastructure.'
|
||||
: 'You don\'t have any dedicated servers yet. Get a dedicated server for maximum performance and full control over your infrastructure.';
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal) }">
|
||||
{{-- Page Header --}}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $title }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage your {{ strtolower($title) }} orders.</p>
|
||||
</div>
|
||||
@if ($serverOrderPayloads !== [])
|
||||
<button type="button" @click="showOrderModal = true"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
New Product
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($hasAnyProducts)
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Your Servers</h2>
|
||||
|
||||
@if ($orders->isNotEmpty())
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50 px-5 py-3">
|
||||
<h3 class="text-sm font-medium text-blue-900">New Server Orders</h3>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Server</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Plan</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Status</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">IP Address</th>
|
||||
<th class="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
@foreach ($orders as $order)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $order->domain_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $order->meta['region'] ?? 'EU' }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<div class="text-sm text-gray-900">{{ $order->product?->name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
@if ($order->product)
|
||||
{{ $order->product->cpu_cores }} vCPU · {{ $order->product->formatRam() }}
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4 font-mono text-sm text-gray-500">
|
||||
{{ $order->server_ip ?? '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4 text-right">
|
||||
<a href="{{ route('servers.orders.show', $order) }}" class="text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Manage →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($rcServiceOrders->isNotEmpty())
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 bg-gradient-to-r from-purple-50 to-pink-50 px-5 py-3">
|
||||
<h3 class="text-sm font-medium text-purple-900">Legacy Service Orders</h3>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Service</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Plan</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Status</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">IP Address</th>
|
||||
<th class="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
@foreach ($rcServiceOrders as $order)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $order->domain_name ?: $order->service_name }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<div class="text-sm text-gray-900">{{ $order->plan_name }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4 font-mono text-sm text-gray-500">
|
||||
{{ $order->ip_address ?? '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-4 text-right">
|
||||
<a href="{{ ladill_servers_url('/hosting') }}" class="text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Manage →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
{{-- Empty State --}}
|
||||
<div class="rounded-xl border border-gray-200 bg-white px-5 py-16 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
{!! $emptyStateIcon !!}
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-4 text-sm font-medium text-gray-900">{{ $emptyStateTitle }}</p>
|
||||
<p class="mt-1 max-w-md mx-auto text-sm text-gray-500">{{ $emptyStateDescription }}</p>
|
||||
@if ($serverOrderPayloads !== [])
|
||||
<div class="mt-6">
|
||||
<button type="button" @click="showOrderModal = true"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Order Modal --}}
|
||||
@if ($serverOrderPayloads !== [])
|
||||
@include('hosting.partials.server-order-modal')
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,424 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Hosting — {{ $order->domain_name }}</x-slot>
|
||||
|
||||
@php
|
||||
$statusColors = [
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'pending' => 'bg-amber-50 text-amber-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
'expired' => 'bg-gray-100 text-gray-600',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500',
|
||||
'failed' => 'bg-red-50 text-red-700',
|
||||
];
|
||||
$statusBadge = $statusColors[$order->status] ?? 'bg-gray-100 text-gray-600';
|
||||
$nameservers = collect((array) $order->nameservers)->filter()->values();
|
||||
$renewalOptions = collect($renewalOptions ?? [])->values();
|
||||
$initialRenewalMonths = (string) ($renewalOptions->first()['months'] ?? '12');
|
||||
$backRoute = match ((string) $order->hosting_type) {
|
||||
'multi_domain' => route('hosting.multi-domain'),
|
||||
'wordpress' => route('hosting.wordpress'),
|
||||
default => route('hosting.single-domain'),
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{
|
||||
passwordModal: false,
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
submittingPassword: false,
|
||||
passwordError: '',
|
||||
passwordSuccess: '',
|
||||
|
||||
openPasswordModal() {
|
||||
this.passwordModal = true;
|
||||
this.password = '';
|
||||
this.passwordConfirmation = '';
|
||||
this.passwordError = '';
|
||||
this.passwordSuccess = '';
|
||||
this.submittingPassword = false;
|
||||
},
|
||||
|
||||
async resetPassword() {
|
||||
if (!this.password || this.password !== this.passwordConfirmation) {
|
||||
this.passwordError = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.submittingPassword = true;
|
||||
this.passwordError = '';
|
||||
this.passwordSuccess = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route('hosting.reset-panel-password', $order) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({ password: this.password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
this.passwordError = data.message || Object.values(data.errors || {}).flat().join(' ') || 'Could not update the password.';
|
||||
return;
|
||||
}
|
||||
this.passwordSuccess = data.message || 'Password updated successfully.';
|
||||
this.password = '';
|
||||
this.passwordConfirmation = '';
|
||||
} catch (e) {
|
||||
this.passwordError = 'Network error. Please try again.';
|
||||
} finally {
|
||||
this.submittingPassword = false;
|
||||
}
|
||||
},
|
||||
}">
|
||||
{{-- Page Header --}}
|
||||
<div>
|
||||
<a href="{{ $backRoute }}" class="inline-flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
Hosting
|
||||
</a>
|
||||
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $order->domain_name }}</h1>
|
||||
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium {{ $statusBadge }}">{{ ucfirst($order->status) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
@if ($order->rc_order_id)
|
||||
Order #{{ $order->rc_order_id }}
|
||||
@else
|
||||
Local order #{{ $order->id }}
|
||||
@endif
|
||||
@if ($order->plan_name) · {{ $order->plan_name }} @endif
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($order->cpanel_url)
|
||||
<a href="{{ $order->cpanel_url }}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
Open cPanel
|
||||
</a>
|
||||
@endif
|
||||
@if ($order->rc_order_id)
|
||||
<button @click="openPasswordModal()" type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50">
|
||||
Set Password
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick Stats --}}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-xs font-medium text-gray-500">Status</p>
|
||||
<p class="mt-1 text-lg font-semibold {{ str_contains($statusBadge, 'emerald') ? 'text-emerald-600' : (str_contains($statusBadge, 'amber') ? 'text-amber-600' : (str_contains($statusBadge, 'red') ? 'text-red-600' : 'text-gray-900')) }}">{{ ucfirst($order->status) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-xs font-medium text-gray-500">IP Address</p>
|
||||
<p class="mt-1 font-mono text-lg font-semibold text-gray-900">{{ $order->ip_address ?: '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-xs font-medium text-gray-500">Plan</p>
|
||||
<p class="mt-1 text-lg font-semibold text-gray-900">{{ $order->plan_name ?: '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-xs font-medium text-gray-500">Expires</p>
|
||||
<p class="mt-1 text-lg font-semibold text-gray-900">{{ $order->expires_at ? $order->expires_at->format('M d, Y') : '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Details + Renew --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(280px,0.8fr)]">
|
||||
{{-- Details Card --}}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Hosting Details</h2>
|
||||
<dl class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Domain</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->domain_name }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Hosting Type</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ str_replace('_', ' ', ucfirst($order->hosting_type)) }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">cPanel Username</dt>
|
||||
<dd class="mt-1 font-mono text-sm font-medium text-gray-900">{{ $order->cpanel_username ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Access Status</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->cpanel_url ? 'Ready' : 'Waiting for provisioning' }}</dd>
|
||||
</div>
|
||||
@if ($order->website_url)
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Website URL</dt>
|
||||
<dd class="mt-1 break-all text-sm font-medium text-gray-900">{{ $order->website_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($order->direct_url)
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Direct URL</dt>
|
||||
<dd class="mt-1 break-all text-sm font-medium text-gray-900">{{ $order->direct_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Disk Space</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->disk_space_mb ? number_format($order->disk_space_mb) . ' MB' : '—' }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Bandwidth</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->bandwidth_mb ? number_format($order->bandwidth_mb) . ' MB' : '—' }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Provisioned At</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->provisioned_at ? $order->provisioned_at->format('M d, Y H:i') : '—' }}</dd>
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3">
|
||||
<dt class="text-xs font-medium text-gray-400">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900">{{ $order->updated_at->format('M d, Y H:i') }}</dd>
|
||||
</div>
|
||||
@if ($order->cpanel_url)
|
||||
<div class="rounded-md bg-gray-50 px-3 py-3 sm:col-span-2">
|
||||
<dt class="text-xs font-medium text-gray-400">Console URL</dt>
|
||||
<dd class="mt-1 break-all text-sm font-medium text-gray-900">{{ $order->cpanel_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Control Panel Access --}}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="flex items-center gap-3 border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white px-5 py-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg {{ $order->cpanel_url ? 'bg-emerald-100' : 'bg-amber-100' }}">
|
||||
<svg class="h-5 w-5 {{ $order->cpanel_url ? 'text-emerald-600' : 'text-amber-600' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Control Panel</h3>
|
||||
<p class="text-xs text-gray-500">{{ $order->cpanel_url ? 'Ready to access' : 'Waiting for provisioning' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Username</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->cpanel_username ?: '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Panel URL</span>
|
||||
<span class="max-w-[180px] truncate text-sm font-medium text-gray-900" title="{{ $order->cpanel_url ?: 'Pending' }}">{{ $order->cpanel_url ? parse_url($order->cpanel_url, PHP_URL_HOST) : 'Pending' }}</span>
|
||||
</div>
|
||||
@if ($order->direct_url)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Direct URL</span>
|
||||
<span class="max-w-[180px] truncate text-sm font-medium text-gray-900" title="{{ $order->direct_url }}">{{ parse_url($order->direct_url, PHP_URL_HOST) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($order->cpanel_url)
|
||||
<div class="border-t border-gray-100 bg-gray-50 px-5 py-3">
|
||||
<a href="{{ $order->cpanel_url }}" target="_blank" rel="noopener"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
Open Control Panel
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Nameservers --}}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="flex items-center gap-3 border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white px-5 py-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
@include('components.icons.domain-globe', ['class' => 'h-5 w-5'])
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Nameservers</h3>
|
||||
<p class="text-xs text-gray-500">Point your domain to this hosting</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($nameservers->isNotEmpty())
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($nameservers as $index => $nameserver)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">NS{{ $index + 1 }}</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $nameserver }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="px-5 py-4 text-center">
|
||||
<p class="text-sm text-gray-500">Nameservers will appear once provisioning is complete.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Server Details --}}
|
||||
@if ($order->ip_address || $order->website_url)
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="flex items-center gap-3 border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white px-5 py-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg class="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Server Details</h3>
|
||||
<p class="text-xs text-gray-500">For DNS and manual configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@if ($order->ip_address)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Server IP</span>
|
||||
<span class="font-mono text-sm font-medium text-gray-900">{{ $order->ip_address }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($order->website_url)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<span class="text-sm text-gray-500">Primary Domain</span>
|
||||
<span class="max-w-[180px] truncate text-sm font-medium text-gray-900" title="{{ $order->website_url }}">{{ $order->website_url }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Renew Card --}}
|
||||
@if (($order->isActive() || ($order->expires_at && $order->expires_at->diffInDays(now()) < 30)) && $renewalOptions->isNotEmpty())
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5" x-data="{
|
||||
renewing: false,
|
||||
months: @js($initialRenewalMonths),
|
||||
error: '',
|
||||
success: '',
|
||||
|
||||
async renew() {
|
||||
this.renewing = true;
|
||||
this.error = '';
|
||||
this.success = '';
|
||||
try {
|
||||
const res = await fetch('{{ route('hosting.renew', $order) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({ months: this.months }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
this.error = data.message || 'Renewal failed.';
|
||||
return;
|
||||
}
|
||||
this.success = 'Hosting renewed successfully!';
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (e) {
|
||||
this.error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
this.renewing = false;
|
||||
}
|
||||
}
|
||||
}">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Renew Hosting</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">Extend your hosting before it expires.</p>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700">Duration</label>
|
||||
<select x-model="months" class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm shadow-sm focus:border-gray-400 focus:ring-0">
|
||||
@foreach ($renewalOptions as $option)
|
||||
<option value="{{ $option['months'] }}">
|
||||
{{ $option['label'] }}
|
||||
@if (! empty($option['total']))
|
||||
- {{ $option['total'] }}
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@if ($renewalOptions->contains(fn (array $option) => ! empty($option['monthly'])))
|
||||
<p class="text-xs text-gray-500">
|
||||
Renewal options are based on the current pricing for this plan.
|
||||
</p>
|
||||
@endif
|
||||
<button @click="renew()" type="button" :disabled="renewing"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800 disabled:opacity-50">
|
||||
<template x-if="renewing">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</template>
|
||||
Renew Hosting
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="error">
|
||||
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" x-text="error"></p>
|
||||
</template>
|
||||
<template x-if="success">
|
||||
<p class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700" x-text="success"></p>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Password Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="passwordModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-center sm:justify-center sm:p-4" @keydown.escape.window="passwordModal = false">
|
||||
<div x-show="passwordModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm hidden sm:block" @click="passwordModal = false"></div>
|
||||
|
||||
<div x-show="passwordModal" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-lg sm:border sm:border-gray-200 bg-white shadow-xl" @click.stop>
|
||||
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Set cPanel Password</h3>
|
||||
<button @click="passwordModal = false" type="button" class="rounded-md p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-5">
|
||||
<p class="text-sm text-gray-500">Choose a new hosting panel password. It must be 9 to 16 characters and include uppercase, lowercase, a number, and a special character.</p>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700">New Password</label>
|
||||
<input x-model="password" type="password" class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm shadow-sm focus:border-gray-400 focus:ring-0">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700">Confirm Password</label>
|
||||
<input x-model="passwordConfirmation" type="password" class="w-full rounded-lg border-gray-200 bg-white px-3 py-2 text-sm shadow-sm focus:border-gray-400 focus:ring-0"
|
||||
@keydown.enter.prevent="resetPassword()">
|
||||
</div>
|
||||
|
||||
<template x-if="passwordError">
|
||||
<p class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" x-text="passwordError"></p>
|
||||
</template>
|
||||
|
||||
<template x-if="passwordSuccess">
|
||||
<p class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700" x-text="passwordSuccess"></p>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<button @click="passwordModal = false" type="button" class="rounded-lg border border-gray-200 px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">Close</button>
|
||||
<button @click="resetPassword()" type="button" :disabled="submittingPassword || !password || !passwordConfirmation"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800 disabled:opacity-50">
|
||||
<template x-if="submittingPassword">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</template>
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signed out — Ladill Hosting</title>
|
||||
@include('partials.favicon')
|
||||
@vite(['resources/css/app.css'])
|
||||
<meta http-equiv="refresh" content="2;url={{ 'https://'.config('app.platform_domain') }}">
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center bg-slate-100 font-sans">
|
||||
<div class="text-center">
|
||||
<img src="{{ asset('images/logo/ladillhosting-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillhosting-logo.svg')) ?: '1' }}" alt="Ladill Hosting" class="mx-auto h-7 w-auto">
|
||||
<p class="mt-6 text-sm text-slate-600">You’ve been signed out. Redirecting…</p>
|
||||
<a href="{{ 'https://'.config('app.platform_domain') }}" class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline">Go to ladill.com</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,381 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $title }}</x-slot>
|
||||
|
||||
@php
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$orderFormErrorFields = ['domain_name', 'plan_id', 'months'];
|
||||
$hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field));
|
||||
$shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors;
|
||||
$knownDomainHosts = $userDomains->pluck('host')->all();
|
||||
$oldDomainName = trim((string) old('domain_name', ''));
|
||||
$initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill';
|
||||
$initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : '';
|
||||
$productOrderFormConfig = [
|
||||
'packages' => $nativeForm['packages'] ?? [],
|
||||
'selectedPlan' => old('plan_id', (string) request('plan_id', $nativeForm['packages'][0]['value'] ?? '')),
|
||||
'selectedMonths' => (string) old('months', ''),
|
||||
];
|
||||
|
||||
$statusColors = [
|
||||
'pending_payment' => 'bg-amber-50 text-amber-700',
|
||||
'pending_approval' => 'bg-amber-50 text-amber-700',
|
||||
'approved' => 'bg-blue-50 text-blue-700',
|
||||
'provisioning' => 'bg-blue-50 text-blue-700',
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500',
|
||||
'expired' => 'bg-gray-100 text-gray-500',
|
||||
'failed' => 'bg-red-50 text-red-700',
|
||||
];
|
||||
|
||||
$statusLabels = collect([
|
||||
'pending_payment', 'pending_approval', 'approved', 'provisioning',
|
||||
'active', 'suspended', 'cancelled', 'expired', 'failed',
|
||||
])->mapWithKeys(fn (string $status) => [
|
||||
$status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status),
|
||||
])->all();
|
||||
$statusColors['approved'] = 'bg-amber-50 text-amber-700';
|
||||
|
||||
$accountStatusColors = [
|
||||
'active' => 'bg-emerald-50 text-emerald-700',
|
||||
'suspended' => 'bg-red-50 text-red-700',
|
||||
'cancelled' => 'bg-gray-100 text-gray-500',
|
||||
];
|
||||
|
||||
$formatMoney = static fn (?float $amount, string $currency = 'GHS'): string => $amount === null
|
||||
? '-'
|
||||
: strtoupper($currency).' '.number_format($amount, 2);
|
||||
|
||||
$hasAnyProducts = $orders->isNotEmpty()
|
||||
|| $hostingAccounts->isNotEmpty()
|
||||
|| $rcHostingOrders->isNotEmpty()
|
||||
|| $rcServiceOrders->isNotEmpty();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{ showOrderModal: @js($shouldOpenOrderModal), domainSource: @js($initialDomainSource), externalDomain: @js($initialExternalDomain) }">
|
||||
{{-- Page Header --}}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($type === \App\Models\HostingProduct::TYPE_WORDPRESS)
|
||||
@include('partials.wordpress-icon', ['class' => 'h-9 w-9 object-contain'])
|
||||
@endif
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ $title }}</h1>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Manage your {{ strtolower($title) }} products and orders.</p>
|
||||
</div>
|
||||
@if ($nativeForm && ($nativeForm['has_packages'] ?? false))
|
||||
<button type="button" @click="showOrderModal = true"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
New Product
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Products List --}}
|
||||
@if ($hasAnyProducts)
|
||||
<div class="space-y-4">
|
||||
|
||||
{{-- Admin-Assigned Hosting Accounts --}}
|
||||
@if ($hostingAccounts->isNotEmpty())
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Active Hosting Accounts</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Your provisioned hosting accounts and linked domains.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50/60 text-left text-xs font-medium text-gray-500">
|
||||
<th class="px-5 py-3">Account</th>
|
||||
<th class="px-5 py-3">Plan</th>
|
||||
<th class="px-5 py-3">Domain</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Expires</th>
|
||||
<th class="px-5 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($hostingAccounts as $account)
|
||||
@php
|
||||
$sharedDeveloperAccess = (int) $account->user_id !== (int) auth()->id();
|
||||
$accountActionUrl = $sharedDeveloperAccess
|
||||
? route('hosting.panel.index', $account)
|
||||
: route('hosting.accounts.show', $account);
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-emerald-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ $account->username ?: '—' }}</p>
|
||||
<p class="text-xs text-gray-500">Hosting account</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600">
|
||||
<p>{{ $account->product?->name ?? 'Hosting Account' }}</p>
|
||||
@if ($sharedDeveloperAccess)
|
||||
<p class="text-xs text-indigo-600">Developer access</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600">{{ $account->linkedDomainLabel() ?? '—' }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $accountStatusColors[$account->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ ucfirst($account->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-500">{{ $account->expires_at?->format('M j, Y') ?? '—' }}</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<a href="{{ $accountActionUrl }}" class="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
{{ $sharedDeveloperAccess ? 'Open Panel' : 'Manage' }}
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- New Hosting Orders --}}
|
||||
@if ($orders->isNotEmpty())
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">New Hosting Orders</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Recent orders still being provisioned or awaiting action.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50/60 text-left text-xs font-medium text-gray-500">
|
||||
<th class="px-5 py-3">Domain</th>
|
||||
<th class="px-5 py-3">Plan</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Expires</th>
|
||||
<th class="px-5 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($orders as $order)
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ filled($order->domain_name) ? $order->domain_name : '—' }}</p>
|
||||
@if ($order->server_ip)
|
||||
<p class="text-xs text-gray-500">{{ $order->server_ip }}</p>
|
||||
@else
|
||||
<p class="text-xs text-gray-500">{{ ucfirst($order->billing_cycle) }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600">
|
||||
<p>{{ $order->product?->name ?? '—' }}</p>
|
||||
<p class="text-xs text-gray-500">{{ ucfirst($order->billing_cycle) }}</p>
|
||||
</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-500">{{ $order->expires_at?->format('M j, Y') ?? '—' }}</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<a href="{{ route('hosting.orders.show', $order) }}" class="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
Manage
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- RC Hosting Orders (Legacy) --}}
|
||||
@if ($rcHostingOrders->isNotEmpty())
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Legacy Hosting Orders</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Hosting orders from the previous ordering system.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50/60 text-left text-xs font-medium text-gray-500">
|
||||
<th class="px-5 py-3">Domain</th>
|
||||
<th class="px-5 py-3">Plan</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Expires</th>
|
||||
<th class="px-5 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($rcHostingOrders as $order)
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-5 py-3 font-medium text-gray-900">{{ filled($order->domain_name) ? $order->domain_name : '—' }}</td>
|
||||
<td class="px-5 py-3 text-gray-600">{{ $order->plan_name }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-500">{{ $order->expires_at?->format('M j, Y') ?? '—' }}</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<a href="{{ route('hosting.show', $order) }}" class="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
Manage
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- RC Service Orders (Legacy) --}}
|
||||
@if ($rcServiceOrders->isNotEmpty())
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Legacy Service Orders</h3>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Older service records carried forward for reference and management.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50/60 text-left text-xs font-medium text-gray-500">
|
||||
<th class="px-5 py-3">Service</th>
|
||||
<th class="px-5 py-3">Plan</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Expires</th>
|
||||
<th class="px-5 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($rcServiceOrders as $order)
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-5 py-3 font-medium text-gray-900">{{ $order->domain_name ?: $order->service_name }}</td>
|
||||
<td class="px-5 py-3 text-gray-600">{{ $order->plan_name }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $statusColors[$order->status] ?? 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $statusLabels[$order->status] ?? ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-500">{{ $order->expires_at?->format('M j, Y') ?? '—' }}</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<a href="{{ route('hosting.orders.refresh', [$order->category, $order]) }}" class="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
Manage
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Empty State - No Products --}}
|
||||
@if (! $hasAnyProducts)
|
||||
<div class="rounded-xl border border-gray-200 bg-white px-5 py-16 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-4 text-sm font-medium text-gray-900">No {{ strtolower($title) }} products</p>
|
||||
<p class="mt-1 max-w-md mx-auto text-sm text-gray-500">You don't have any {{ strtolower($title) }} products yet. Get started with reliable web hosting for your websites.</p>
|
||||
@if ($nativeForm && ($nativeForm['has_packages'] ?? false))
|
||||
<div class="mt-6">
|
||||
<button type="button" @click="showOrderModal = true"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-gray-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Order Modal --}}
|
||||
@if ($nativeForm && ($nativeForm['has_packages'] ?? false))
|
||||
<template x-teleport="body">
|
||||
<div x-show="showOrderModal" x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto sm:flex sm:items-start sm:justify-center sm:p-4 sm:pt-24"
|
||||
@keydown.escape.window="showOrderModal = false">
|
||||
<div x-show="showOrderModal" x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm hidden sm:block" @click="showOrderModal = false"></div>
|
||||
|
||||
<div x-show="showOrderModal" x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg overflow-hidden sm:rounded-2xl sm:border sm:border-gray-200 bg-white shadow-xl"
|
||||
@click.stop
|
||||
x-data='{ showAdvanced: false, ...rcProductOrderForm(@json($productOrderFormConfig)) }'>
|
||||
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">New {{ $title }}</h2>
|
||||
<p class="text-sm text-gray-500">Add to cart and checkout when ready</p>
|
||||
</div>
|
||||
<button type="button" @click="showOrderModal = false" class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('hosting.orders.store') }}">
|
||||
@csrf
|
||||
<div class="space-y-5 px-6 py-5">
|
||||
@include('hosting.partials.order-form-fields')
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-100 bg-gray-50 px-6 py-4">
|
||||
<p class="text-xs text-gray-400">Region: {{ $nativeForm['region'] ?? 'Global' }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" @click="showOrderModal = false"
|
||||
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-500 transition hover:text-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-gray-800">
|
||||
Add to Cart
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Ladill Email')</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
@include('partials.mailbox-link-banner')
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@php
|
||||
$navUser = auth()->user();
|
||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||
->filter()->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
$navAcct = 'https://'.config('app.account_domain');
|
||||
@endphp
|
||||
@include('partials.mobile-bottom-nav', [
|
||||
'homeUrl' => route('email.dashboard'),
|
||||
'homeActive' => request()->routeIs('email.dashboard'),
|
||||
'searchUrl' => route('email.mailboxes.index'),
|
||||
'searchActive' => request()->routeIs('email.mailboxes.*'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => [
|
||||
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
|
||||
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
|
||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
||||
],
|
||||
'avatarUrl' => $navUser?->avatar_url,
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Ladill Servers')</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
|
||||
<div class="min-h-screen max-lg:min-h-dvh">
|
||||
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
|
||||
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
@include('partials.sidebar')
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
|
||||
@include('partials.topbar')
|
||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||
@include('partials.flash')
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@php
|
||||
$navUser = auth()->user();
|
||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||
->filter()->take(2)
|
||||
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
|
||||
->implode('');
|
||||
$navAcct = 'https://'.config('app.account_domain');
|
||||
@endphp
|
||||
@include('partials.mobile-bottom-nav', [
|
||||
'homeUrl' => route('servers.dashboard'),
|
||||
'homeActive' => request()->routeIs('servers.dashboard') || request()->routeIs('hosting.index'),
|
||||
'searchUrl' => route('servers.dashboard'),
|
||||
'searchActive' => request()->routeIs('hosting.*'),
|
||||
'notificationsUrl' => route('notifications.index'),
|
||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||
'unreadUrl' => route('notifications.unread'),
|
||||
'profileActive' => false,
|
||||
'profileName' => $navUser?->name ?? '',
|
||||
'profileSubtitle' => $navUser?->email ?? '',
|
||||
'profileMenuItems' => [
|
||||
['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'],
|
||||
['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'],
|
||||
['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')],
|
||||
],
|
||||
'avatarUrl' => $navUser?->avatar_url,
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Notifications</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $subtitle ?? 'Stay updated on your account activity.' }}</p>
|
||||
</div>
|
||||
@if ($notifications->where('read_at', null)->count() > 0)
|
||||
<form method="POST" action="{{ route('notifications.mark-all-read') }}">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
Mark all as read
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white">
|
||||
@forelse ($notifications as $notification)
|
||||
@php
|
||||
$data = $notification->data;
|
||||
$icon = $data['icon'] ?? 'bell';
|
||||
$iconBg = match ($icon) {
|
||||
'domain' => 'bg-emerald-50',
|
||||
'hosting' => 'bg-violet-50',
|
||||
'email' => 'bg-pink-50',
|
||||
'billing' => 'bg-amber-50',
|
||||
'success' => 'bg-green-50',
|
||||
default => 'bg-slate-100',
|
||||
};
|
||||
$iconColor = match ($icon) {
|
||||
'domain' => 'text-emerald-600',
|
||||
'hosting' => 'text-violet-600',
|
||||
'email' => 'text-pink-600',
|
||||
'billing' => 'text-amber-600',
|
||||
'success' => 'text-green-600',
|
||||
default => 'text-slate-500',
|
||||
};
|
||||
@endphp
|
||||
<div class="flex items-start gap-4 border-b border-gray-100 px-5 py-4 last:border-b-0 {{ $notification->read_at ? 'bg-white' : 'bg-indigo-50/30' }}">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {{ $iconBg }}">
|
||||
@if ($icon === 'domain' && view()->exists('components.icons.domain-globe'))
|
||||
@include('components.icons.domain-globe', ['class' => 'h-5 w-5 ' . $iconColor])
|
||||
@elseif ($icon === 'email')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
@elseif ($icon === 'hosting')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
@elseif ($icon === 'billing')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
@elseif ($icon === 'success')
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
@else
|
||||
<svg class="h-5 w-5 {{ $iconColor }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@endif
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $data['title'] ?? 'Notification' }}</p>
|
||||
<p class="mt-0.5 text-sm text-gray-600">{{ $data['message'] ?? '' }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
@if (! empty($data['url']))
|
||||
<a href="{{ $data['url'] }}" class="mt-2 inline-flex text-sm font-medium text-indigo-600 hover:text-indigo-700">
|
||||
View details →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@if (! $notification->read_at)
|
||||
<form method="POST" action="{{ route('notifications.mark-read', $notification->id) }}" class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600" title="Mark as read">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-5 py-16 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-gray-900">No notifications</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $emptyMessage ?? "You're all caught up." }}</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($notifications->hasPages())
|
||||
<div class="mt-4">{{ $notifications->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.hosting')
|
||||
|
||||
@section('title', 'Notifications')
|
||||
|
||||
@section('content')
|
||||
@include('notifications._list', [
|
||||
'subtitle' => 'Hosting activity for your account.',
|
||||
'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.",
|
||||
])
|
||||
@endsection
|
||||
@@ -0,0 +1,102 @@
|
||||
@php
|
||||
$afiaProduct = $afiaProduct ?? (request()->routeIs('email.*') ? 'email' : 'hosting');
|
||||
$afiaGreeting = $afiaProduct === 'email'
|
||||
? "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…"
|
||||
: "Hi, I'm Afia 👋 Ask me anything about hosting — choosing a plan, linking a domain, using the control panel, SSL, or renewals…";
|
||||
$afiaSuggestions = $afiaProduct === 'email'
|
||||
? ['How do I set up email for my domain?', 'What DNS records do I need to verify?', 'How do I create a mailbox?', 'What are my IMAP and SMTP settings?']
|
||||
: ['How do I link a domain to my hosting?', 'How do I open the control panel?', 'How do I renew my hosting plan?', 'How do I install WordPress?'];
|
||||
$afiaSubtitle = $afiaProduct === 'email' ? 'Email assistant' : 'Hosting assistant';
|
||||
@endphp
|
||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ $afiaProduct === 'email' ? route('email.afia.chat') : route('hosting.afia.chat') }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
greeting: @js($afiaGreeting),
|
||||
suggestions: @js($afiaSuggestions),
|
||||
})">
|
||||
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
|
||||
|
||||
<section x-show="open" x-cloak
|
||||
x-transition:enter="transition transform ease-out duration-200"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition transform ease-in duration-150"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="14" fill="url(#afia-orb)">
|
||||
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
|
||||
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle r="1.2" fill="#c7d2fe" opacity=".8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||
<p class="text-[11px] text-slate-400">{{ $afiaSubtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
|
||||
<div class="space-y-3">
|
||||
<template x-for="(m, i) in messages" :key="i">
|
||||
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
|
||||
x-text="m.text"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="loading" class="flex justify-start">
|
||||
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
|
||||
<template x-for="s in suggestions" :key="s">
|
||||
<button @click="useSuggestion(s)" :disabled="loading"
|
||||
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
|
||||
<span class="text-indigo-400">→</span><span x-text="s"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
|
||||
<form @submit.prevent="send()" class="flex items-end gap-2">
|
||||
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<button type="submit" :disabled="loading || !input.trim()"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition hover:bg-indigo-700 disabled:opacity-40">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes — verify important details.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
@php $faviconVer = @filemtime(public_path('favicon.ico')) ?: '1'; @endphp
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}?v={{ $faviconVer }}" sizes="any">
|
||||
<link rel="shortcut icon" href="{{ asset('favicon.ico') }}?v={{ $faviconVer }}">
|
||||
@@ -0,0 +1,22 @@
|
||||
@php
|
||||
$flashStyles = [
|
||||
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-800',
|
||||
'info' => 'border-sky-200 bg-sky-50 text-sky-800',
|
||||
'error' => 'border-red-200 bg-red-50 text-red-800',
|
||||
];
|
||||
@endphp
|
||||
@foreach ($flashStyles as $key => $classes)
|
||||
@if (session($key))
|
||||
<div class="mb-4 rounded-lg border px-4 py-3 text-sm {{ $classes }}">{{ session($key) }}</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $err)
|
||||
<li>{{ $err }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,2 @@
|
||||
@props(['class' => 'h-5 w-5'])
|
||||
<img src="{{ asset('images/ladill-icons/pro.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/pro.svg')) ?: '1' }}" alt="" @class([$class]) width="14" height="14">
|
||||
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
// Shared Ladill app launcher — IDENTICAL across every app/service.
|
||||
// Driven by config/ladill_launcher.php (fully-extracted apps only) + icons
|
||||
// from public/images/launcher-icons/. Omits the current app (matched by
|
||||
// APP_URL host). To replicate elsewhere, copy this file + the config +
|
||||
// the launcher-icons folder verbatim.
|
||||
$sidebar = (bool) ($sidebar ?? false);
|
||||
$selfHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$launcherApps = array_values(array_filter(
|
||||
config('ladill_launcher.apps', []),
|
||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||
));
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
<div class="relative" x-data="{ appsOpen: false }" @click.outside="appsOpen = false" @keydown.escape.window="appsOpen = false">
|
||||
<button type="button" @click="appsOpen = !appsOpen"
|
||||
@class([
|
||||
'inline-flex items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:bg-slate-50',
|
||||
'p-2' => ! $sidebar,
|
||||
'w-full gap-3 px-3 py-2 text-[13px] hover:text-slate-900' => $sidebar,
|
||||
])
|
||||
:class="appsOpen ? 'bg-slate-50 text-slate-900' : ''" aria-label="All apps">
|
||||
<svg @class(['shrink-0', 'h-5 w-5' => ! $sidebar, 'h-[18px] w-[18px]' => $sidebar]) viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 0.5H1.5C0.947715 0.5 0.5 0.947715 0.5 1.5V4.5C0.5 5.05228 0.947715 5.5 1.5 5.5H4.5C5.05228 5.5 5.5 5.05228 5.5 4.5V1.5C5.5 0.947715 5.05228 0.5 4.5 0.5Z"/>
|
||||
<path d="M12.5 0.5H9.5C8.94772 0.5 8.5 0.947715 8.5 1.5V4.5C8.5 5.05228 8.94772 5.5 9.5 5.5H12.5C13.0523 5.5 13.5 5.05228 13.5 4.5V1.5C13.5 0.947715 13.0523 0.5 12.5 0.5Z"/>
|
||||
<path d="M4.5 8.5H1.5C0.947715 8.5 0.5 8.94772 0.5 9.5V12.5C0.5 13.0523 0.947715 13.5 1.5 13.5H4.5C5.05228 13.5 5.5 13.0523 5.5 12.5V9.5C5.5 8.94772 5.05228 8.5 4.5 8.5Z"/>
|
||||
<path d="M12.5 8.5H9.5C8.94772 8.5 8.5 8.94772 8.5 9.5V12.5C8.5 13.0523 8.94772 13.5 9.5 13.5H12.5C13.0523 13.5 13.5 13.0523 13.5 12.5V9.5C13.5 8.94772 13.0523 8.5 12.5 8.5Z"/>
|
||||
</svg>
|
||||
@if ($sidebar)
|
||||
<span class="font-medium">All Ladill apps</span>
|
||||
@endif
|
||||
</button>
|
||||
<div x-show="appsOpen" x-cloak x-transition.origin.top.right
|
||||
@class([
|
||||
'absolute z-50 w-72 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl',
|
||||
'right-0 mt-2' => ! $sidebar,
|
||||
'bottom-full left-0 mb-2' => $sidebar,
|
||||
])>
|
||||
<p class="px-1 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">All apps</p>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@foreach ($launcherApps as $app)
|
||||
<a href="{{ $app['url'] }}"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-center transition hover:bg-indigo-50">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100">
|
||||
<img src="{{ asset('images/launcher-icons/'.$app['icon']) }}?v={{ @filemtime(public_path('images/launcher-icons/'.$app['icon'])) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</span>
|
||||
<span class="text-[11px] font-medium leading-tight text-slate-700">{{ $app['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,46 @@
|
||||
@if(($mailboxLinkReminder['visible'] ?? false) === true)
|
||||
@php
|
||||
$stage = $mailboxLinkReminder['stage'] ?? 'needs_link';
|
||||
$copy = match ($stage) {
|
||||
'needs_domain' => [
|
||||
'title' => 'Set up Ladill Email',
|
||||
'body' => 'Add and verify an email domain before you can create a mailbox and link it to your Ladill account.',
|
||||
'cta' => 'Add email domain',
|
||||
'url' => route('email.domains.index'),
|
||||
],
|
||||
'needs_mailbox' => [
|
||||
'title' => 'Create your Ladill mailbox',
|
||||
'body' => 'Your domain is ready. Create a mailbox, then link it in Settings so Ladill Mail opens with your Ladill sign-in.',
|
||||
'cta' => 'Create mailbox',
|
||||
'url' => route('email.mailboxes.create'),
|
||||
],
|
||||
default => [
|
||||
'title' => 'Link your Ladill mailbox',
|
||||
'body' => 'Your account uses '.$mailboxLinkReminder['account_email'].' — choose a mailbox in Settings to connect Ladill Mail sign-in.',
|
||||
'cta' => 'Link mailbox',
|
||||
'url' => route('account.settings'),
|
||||
],
|
||||
};
|
||||
@endphp
|
||||
<div class="mb-4 rounded-xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-sky-50 px-4 py-3.5 text-sm text-slate-800 shadow-sm" role="status">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white shadow-sm" aria-hidden="true">
|
||||
@include('partials.ladill-pro-icon', ['class' => 'h-4 w-4'])
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-slate-900">{{ $copy['title'] }}</p>
|
||||
<p class="mt-1 leading-relaxed text-slate-600">{{ $copy['body'] }}</p>
|
||||
<a href="{{ $copy['url'] }}" class="mt-2.5 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
|
||||
{{ $copy['cta'] }}
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('account.mailbox-link-banner.dismiss') }}" class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg p-1.5 text-slate-400 transition hover:bg-white/70 hover:text-slate-600" aria-label="Dismiss for now">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,137 @@
|
||||
@php
|
||||
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
|
||||
$avatarUrl = $avatarUrl ?? null;
|
||||
$initials = $initials ?? 'U';
|
||||
$notificationsUrl = $notificationsUrl ?? '#';
|
||||
$unreadUrl = $unreadUrl ?? null;
|
||||
$profileUrl = $profileUrl ?? '#';
|
||||
$profileName = trim((string) ($profileName ?? ''));
|
||||
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
|
||||
$profileMenuItems = $profileMenuItems ?? [];
|
||||
if ($profileMenuItems === [] && $profileUrl !== '#') {
|
||||
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
|
||||
}
|
||||
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
|
||||
@endphp
|
||||
<div x-data="{ profileOpen: false }" class="lg:hidden">
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="grid h-16 {{ $gridCols }}">
|
||||
<a href="{{ $homeUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
|
||||
<span class="text-[10px] font-medium">Home</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ $searchUrl }}"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||
<span class="text-[10px] font-medium">Search</span>
|
||||
</a>
|
||||
|
||||
@if (!empty($centerCompose))
|
||||
<div class="flex items-center justify-center">
|
||||
<button type="button"
|
||||
@click="$dispatch('compose-open')"
|
||||
class="flex h-11 w-11 -translate-y-1 items-center justify-center rounded-full bg-indigo-600 text-white shadow-md ring-4 ring-white transition hover:bg-indigo-700"
|
||||
aria-label="Compose">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ $notificationsUrl }}"
|
||||
@if ($unreadUrl)
|
||||
x-data="{
|
||||
unread: 0,
|
||||
init() {
|
||||
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
|
||||
},
|
||||
}"
|
||||
@endif
|
||||
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
@if ($unreadUrl)
|
||||
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
|
||||
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Notifications</span>
|
||||
</a>
|
||||
|
||||
<button type="button"
|
||||
@click="profileOpen = true"
|
||||
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
|
||||
:class="profileOpen ? 'text-indigo-600' : ''"
|
||||
aria-label="Open profile menu">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<span class="text-[10px] font-medium">Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
|
||||
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
|
||||
<div x-show="profileOpen" x-transition.opacity.duration.200ms
|
||||
@click="profileOpen = false"
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
|
||||
|
||||
<div x-show="profileOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full"
|
||||
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
|
||||
@click.outside="profileOpen = false">
|
||||
<div class="flex justify-center pt-3">
|
||||
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
@if ($profileName !== '' || $profileSubtitle !== '')
|
||||
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
|
||||
@if ($avatarUrl)
|
||||
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
@if ($profileName !== '')
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
|
||||
@endif
|
||||
@if ($profileSubtitle !== '')
|
||||
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-1 p-2 pb-4">
|
||||
@foreach ($profileMenuItems as $item)
|
||||
@if (($item['type'] ?? 'link') === 'link')
|
||||
<a href="{{ $item['href'] }}"
|
||||
@click="profileOpen = false"
|
||||
class="block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@elseif (($item['type'] ?? '') === 'logout')
|
||||
<form method="POST" action="{{ $item['action'] }}" class="border-t border-slate-100 pt-2">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50">
|
||||
{{ $item['label'] }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Mobile header cart icon — only include when the app has a shopping cart. --}}
|
||||
<a href="{{ $cartUrl }}"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-50 lg:hidden"
|
||||
aria-label="Cart">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
|
||||
@if (($cartCount ?? 0) > 0)
|
||||
<span class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white">{{ $cartCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@@ -0,0 +1,106 @@
|
||||
{{-- In-app notification bell + dropdown (scoped to this app). --}}
|
||||
<div class="relative hidden lg:block"
|
||||
x-data="notificationDropdown({
|
||||
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
|
||||
markReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-read', ['id' => '__ID__'])) }},
|
||||
markAllReadUrl: {{ \Illuminate\Support\Js::from(route('notifications.mark-all-read')) }},
|
||||
indexUrl: {{ \Illuminate\Support\Js::from(route('notifications.index')) }},
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
})"
|
||||
@click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="toggle()"
|
||||
class="relative inline-flex items-center justify-center rounded-full border border-slate-200 p-2.5 text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label="Notifications">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.7" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<span x-show="unreadCount > 0"
|
||||
x-cloak
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"
|
||||
class="absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Notifications</h3>
|
||||
<button type="button"
|
||||
x-show="unreadCount > 0"
|
||||
@click="markAllRead()"
|
||||
class="text-xs font-medium text-indigo-600 hover:text-indigo-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-6 text-center">
|
||||
<svg class="mx-auto h-5 w-5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length === 0">
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && notifications.length > 0">
|
||||
<div class="divide-y divide-slate-100">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<a :href="notification.url || '#'"
|
||||
@click="markRead(notification.id)"
|
||||
class="flex items-start gap-3 px-4 py-3 transition hover:bg-slate-50">
|
||||
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="getIconBg(notification.icon)">
|
||||
<template x-if="notification.icon === 'domain'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.465.727-3.556"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'hosting'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'email'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'billing'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</template>
|
||||
<template x-if="notification.icon === 'success'">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
</template>
|
||||
<template x-if="!['domain','hosting','email','billing','success'].includes(notification.icon)">
|
||||
<svg class="h-4 w-4" :class="getIconColor(notification.icon)" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
|
||||
</template>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-slate-900 truncate" x-text="notification.title"></p>
|
||||
<p class="text-xs text-slate-500 line-clamp-2" x-text="notification.message"></p>
|
||||
<p class="mt-1 text-[11px] text-slate-400" x-text="notification.created_at"></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 py-2.5">
|
||||
<a :href="indexUrl" class="block text-center text-xs font-medium text-slate-600 hover:text-slate-900">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
{{--
|
||||
Paystack mobile bottom-sheet iframe.
|
||||
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
|
||||
On mobile (< md) the sheet slides up; on desktop nothing renders.
|
||||
--}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="showSheet"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[9999] flex flex-col justify-end md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
|
||||
{{-- Backdrop --}}
|
||||
<div class="absolute inset-0 bg-black/60"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-opacity duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="showSheet = false">
|
||||
</div>
|
||||
|
||||
{{-- Sheet --}}
|
||||
<div class="relative flex flex-col bg-white rounded-t-2xl overflow-hidden"
|
||||
style="height:90dvh"
|
||||
x-show="showSheet"
|
||||
x-transition:enter="transition-transform duration-300 ease-out"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition-transform duration-200 ease-in"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full">
|
||||
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200"></div>
|
||||
<p class="text-sm font-semibold text-slate-800">Complete Payment</p>
|
||||
<button @click="showSheet = false"
|
||||
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="Close">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<iframe :src="showSheet ? checkoutUrl : ''"
|
||||
class="flex-1 w-full border-0"
|
||||
allow="payment"
|
||||
title="Paystack checkout"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700">
|
||||
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6">
|
||||
<a href="{{ route('servers.dashboard') }}" class="flex items-center">
|
||||
<img src="{{ asset('images/logo/ladillservers-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillservers-logo.svg')) ?: '1' }}" alt="Ladill Servers" class="h-6 w-auto">
|
||||
</a>
|
||||
</div>
|
||||
@php
|
||||
$main = [
|
||||
['name' => 'Dashboard', 'route' => route('servers.dashboard'), 'active' => request()->routeIs('servers.dashboard'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
|
||||
['name' => 'VPS', 'route' => route('servers.vps'), 'active' => request()->routeIs('servers.vps'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />'],
|
||||
['name' => 'Dedicated', 'route' => route('servers.dedicated'), 'active' => request()->routeIs('servers.dedicated'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3" />'],
|
||||
];
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach($main as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
<p class="px-3 pb-1 pt-5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Account</p>
|
||||
@php
|
||||
$accountNav = [
|
||||
['name' => 'Wallet', 'route' => route('account.wallet'), 'active' => request()->routeIs('account.wallet'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 12m18 0v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 9m18 0V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v3" />'],
|
||||
['name' => 'Billing', 'route' => route('account.billing'), 'active' => request()->routeIs('account.billing'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 6.75 19.5Z" />'],
|
||||
['name' => 'Team', 'route' => route('account.team'), 'active' => request()->routeIs('account.team'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
|
||||
['name' => 'Settings', 'route' => route('account.settings'), 'active' => request()->routeIs('account.settings'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($accountNav as $item)
|
||||
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
|
||||
<span>{{ $item['name'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
@php $u = auth()->user(); $acct = 'https://'.config('app.account_domain'); @endphp
|
||||
<header class="flex items-center justify-between h-16 border-b border-slate-200 bg-white px-6">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden shrink-0 text-slate-500 hover:text-slate-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
{{-- Search hosting accounts, domains, orders --}}
|
||||
<div class="relative hidden w-full max-w-md lg:block" x-data="topbarSearch({ searchUrl: @js(route('servers.search')) })" @click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<div class="relative">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.2-5.2m0 0A7.5 7.5 0 1 0 5.2 5.2a7.5 7.5 0 0 0 10.6 10.6Z"/></svg>
|
||||
<input type="text" x-model="query" @focus="onFocus()" @input.debounce.200ms="search()" @keydown.arrow-down.prevent="moveDown()" @keydown.arrow-up.prevent="moveUp()" @keydown.enter.prevent="go()" placeholder="Search servers…"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-3 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||
</div>
|
||||
<div x-show="open && (results.length > 0 || (query.length >= 2 && !loading))" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full z-30 mt-1.5 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<template x-if="loading"><div class="px-4 py-3 text-center text-xs text-slate-400">Searching…</div></template>
|
||||
<template x-if="!loading && results.length === 0 && query.length >= 2"><div class="px-4 py-3 text-center text-xs text-slate-500">No results for "<span x-text="query" class="font-medium"></span>"</div></template>
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<ul class="max-h-72 overflow-y-auto py-1">
|
||||
<template x-for="(item, idx) in results" :key="item.url">
|
||||
<li>
|
||||
<a :href="item.url" :class="idx === active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'" @mouseenter="active = idx" class="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-indigo-50">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-teal-100 text-teal-600">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium leading-tight" x-text="item.title"></span>
|
||||
<span class="block truncate text-xs text-slate-400" x-text="item.subtitle"></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
@auth
|
||||
@include('partials.notification-dropdown')
|
||||
|
||||
<span class="hidden h-7 w-px bg-slate-200 lg:block"></span>
|
||||
|
||||
{{-- Account switcher (only when the user belongs to more than one account) --}}
|
||||
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
|
||||
<div x-data="{ open: false }" class="relative hidden lg:block">
|
||||
<button @click="open = !open" class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
||||
<span class="max-w-[120px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
|
||||
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
|
||||
@foreach ($accessibleAccounts as $acctOption)
|
||||
<form method="POST" action="{{ route('account.switch') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="account" value="{{ $acctOption->id }}">
|
||||
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
|
||||
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
|
||||
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600">✓</span>@endif
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Profile / avatar menu (desktop; mobile uses bottom nav) --}}
|
||||
<div class="relative hidden lg:block" x-data="{ open: false }">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 p-1 pr-2 text-slate-700 hover:bg-slate-50">
|
||||
@if ($u?->avatar_url)
|
||||
<img src="{{ $u->avatar_url }}" alt="" class="h-8 w-8 rounded-full object-cover ring-1 ring-slate-200">
|
||||
@else
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($u->name ?? $u->email, 0, 1)) }}</span>
|
||||
@endif
|
||||
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 z-20 mt-2 w-48 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
|
||||
<a href="{{ $acct }}/profile" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Profile</a>
|
||||
<a href="{{ $acct }}/account-settings" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
|
||||
<form method="POST" action="{{ route('logout') }}">@csrf
|
||||
<button type="submit" class="w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Afia (AI) — opens the in-app assistant. Matches the platform AI button. --}}
|
||||
<button type="button" @click="$dispatch('afia-open')"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:opacity-95">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
|
||||
</svg>
|
||||
<span>AI</span>
|
||||
</button>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Sign in</a>
|
||||
@endauth
|
||||
|
||||
@include('partials.launcher')
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,2 @@
|
||||
@props(['class' => 'h-8 w-8'])
|
||||
<img src="{{ asset('images/ladill-icons/wordpress.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/wordpress.svg')) ?: '1' }}" alt="WordPress" @class([$class])>
|
||||
@@ -0,0 +1,96 @@
|
||||
@extends('layouts.hosting')
|
||||
@section('title', 'Overview — Ladill Servers')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your VPS and dedicated servers at a glance.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('servers.vps') }}" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700">
|
||||
New VPS
|
||||
</a>
|
||||
<a href="{{ route('servers.dedicated') }}" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
New dedicated
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach([
|
||||
['Active servers', $activeCount, route('servers.vps')],
|
||||
['VPS', $vpsCount, route('servers.vps')],
|
||||
['Dedicated', $dedicatedCount, route('servers.dedicated')],
|
||||
['Pending orders', $pendingOrderCount, route('servers.vps')],
|
||||
] as [$label, $value, $link])
|
||||
<a href="{{ $link }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">{{ $label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $value }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Your servers</h2>
|
||||
</div>
|
||||
@if ($recent->isEmpty())
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">No servers yet</p>
|
||||
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Deploy a VPS or dedicated server — configure region, image, and billing in minutes.</p>
|
||||
<div class="mt-5 flex flex-wrap justify-center gap-2">
|
||||
<a href="{{ route('servers.vps') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Browse VPS</a>
|
||||
<a href="{{ route('servers.dedicated') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Browse dedicated</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach ($recent as $order)
|
||||
@php
|
||||
$panelUrl = $order->control_panel_url ?: route('servers.orders.show', $order);
|
||||
@endphp
|
||||
<a href="{{ $panelUrl }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-slate-900">{{ $order->domain_name ?: ($order->product?->name ?? 'Server') }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">
|
||||
{{ $order->product?->name ?? 'Server' }}
|
||||
@if ($order->server_ip)
|
||||
· {{ $order->server_ip }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span @class([
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium capitalize',
|
||||
'bg-emerald-50 text-emerald-700' => $order->status === 'active',
|
||||
'bg-amber-50 text-amber-700' => in_array($order->status, ['pending_payment', 'pending_approval', 'approved', 'provisioning'], true),
|
||||
'bg-slate-100 text-slate-600' => ! in_array($order->status, ['active', 'pending_payment', 'pending_approval', 'approved', 'provisioning'], true),
|
||||
])>{{ \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($order->status) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pendingOrders->isNotEmpty())
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Pending orders</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-50">
|
||||
@foreach ($pendingOrders as $order)
|
||||
<a href="{{ route('servers.orders.show', $order) }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-slate-900">{{ $order->domain_name ?: ($order->product?->name ?? 'Server order') }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $order->product?->name ?? 'Server' }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">
|
||||
{{ \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($order->status) }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Signed out — Ladill Servers</title>
|
||||
@include('partials.favicon')
|
||||
@vite(['resources/css/app.css'])
|
||||
<meta http-equiv="refresh" content="2;url={{ 'https://'.config('app.platform_domain') }}">
|
||||
</head>
|
||||
<body class="flex h-full items-center justify-center bg-slate-100 font-sans">
|
||||
<div class="text-center">
|
||||
<img src="{{ asset('images/logo/ladillservers-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillservers-logo.svg')) ?: '1' }}" alt="Ladill Servers" class="mx-auto h-7 w-auto">
|
||||
<p class="mt-6 text-sm text-slate-600">You’ve been signed out. Redirecting…</p>
|
||||
<a href="{{ 'https://'.config('app.platform_domain') }}" class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline">Go to ladill.com</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user