Replace the smart redirect on /dashboard with an overview hub (stats, recent accounts, pending orders) matching Domains and Email, and move the full account list to /hosting/accounts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,71 +25,6 @@ use Illuminate\View\View;
|
||||
|
||||
class HostingProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Smart hosting redirect - routes user based on their hosting accounts
|
||||
*/
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get all shared hosting types (not VPS/dedicated)
|
||||
$sharedTypes = [
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN,
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
|
||||
// Get user's hosting accounts (owned + team member access)
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with('product')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get user's hosting orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with(['product', 'hostingAccount'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Combine accounts from orders and direct assignments
|
||||
$allAccounts = $hostingAccounts->merge(
|
||||
$orders->filter(fn ($o) => $o->hostingAccount)->map(fn ($o) => $o->hostingAccount)
|
||||
)->unique('id');
|
||||
|
||||
// No hosting accounts - show selection modal
|
||||
if ($allAccounts->isEmpty()) {
|
||||
return view('hosting.select-type', [
|
||||
'title' => 'Choose Hosting Plan',
|
||||
]);
|
||||
}
|
||||
|
||||
// Single account — account overview (matches monolith; panel via Control Panel)
|
||||
if ($allAccounts->count() === 1) {
|
||||
$account = $allAccounts->first();
|
||||
|
||||
return redirect()->route('hosting.accounts.show', $account);
|
||||
}
|
||||
|
||||
// Multiple accounts - show list page
|
||||
return view('hosting.accounts-list', [
|
||||
'title' => 'Your Hosting Accounts',
|
||||
'accounts' => $allAccounts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function singleDomain(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_SINGLE_DOMAIN, 'Single Domain Hosting');
|
||||
|
||||
@@ -3,13 +3,114 @@
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function index(Request $request): RedirectResponse
|
||||
/** Authenticated hosting dashboard (overview hub). */
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return redirect()->route('hosting.dashboard');
|
||||
$user = $request->user();
|
||||
$accounts = $this->sharedHostingAccounts($user);
|
||||
$accountIds = $accounts->pluck('id');
|
||||
|
||||
$pendingOrders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $this->sharedTypes()))
|
||||
->whereIn('status', [
|
||||
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
|
||||
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
CustomerHostingOrder::STATUS_APPROVED,
|
||||
CustomerHostingOrder::STATUS_PROVISIONING,
|
||||
])
|
||||
->with('product')
|
||||
->latest()
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
$linkedDomains = $accountIds->isEmpty()
|
||||
? 0
|
||||
: HostedSite::query()->whereIn('hosting_account_id', $accountIds)->count();
|
||||
|
||||
return view('hosting.dashboard', [
|
||||
'accounts' => $accounts,
|
||||
'activeCount' => $accounts->where('status', HostingAccount::STATUS_ACTIVE)->count(),
|
||||
'expiringCount' => $accounts
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->filter(fn (HostingAccount $account) => $account->expires_at !== null && $account->expires_at->between(now(), now()->addDays(30)))
|
||||
->count(),
|
||||
'pendingOrderCount' => CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $this->sharedTypes()))
|
||||
->whereIn('status', [
|
||||
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
|
||||
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
CustomerHostingOrder::STATUS_APPROVED,
|
||||
CustomerHostingOrder::STATUS_PROVISIONING,
|
||||
])
|
||||
->count(),
|
||||
'linkedDomains' => $linkedDomains,
|
||||
'recent' => $accounts->take(5),
|
||||
'pendingOrders' => $pendingOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accounts(Request $request): View
|
||||
{
|
||||
return view('hosting.accounts-list', [
|
||||
'title' => 'Your Hosting Accounts',
|
||||
'accounts' => $this->sharedHostingAccounts($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sharedTypes(): array
|
||||
{
|
||||
return [
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN,
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return Collection<int, HostingAccount> */
|
||||
private function sharedHostingAccounts(User $user): Collection
|
||||
{
|
||||
$sharedTypes = $this->sharedTypes();
|
||||
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with(['product', 'sites'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$orderAccounts = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with(['hostingAccount.product', 'hostingAccount.sites'])
|
||||
->latest()
|
||||
->get()
|
||||
->filter(fn (CustomerHostingOrder $order) => $order->hostingAccount !== null)
|
||||
->map(fn (CustomerHostingOrder $order) => $order->hostingAccount);
|
||||
|
||||
return $hostingAccounts->merge($orderAccounts)->unique('id')->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<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="mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $title }}</h1>
|
||||
<p class="mt-1 text-slate-600">Select a hosting account to manage</p>
|
||||
</div>
|
||||
@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)
|
||||
@@ -76,6 +76,5 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
@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->primary_domain ?: $account->username }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">
|
||||
{{ $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
|
||||
+4
-2
@@ -9,6 +9,7 @@ use App\Http\Controllers\Hosting\HostingPanelController;
|
||||
use App\Http\Controllers\Hosting\HostingProductController;
|
||||
use App\Http\Controllers\Hosting\HostingTerminalSessionController;
|
||||
use App\Http\Controllers\Hosting\MailboxLinkBannerController;
|
||||
use App\Http\Controllers\Hosting\OverviewController;
|
||||
use App\Http\Controllers\Hosting\TeamController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -28,9 +29,10 @@ Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannel
|
||||
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('hosting.dashboard') : view('hosting.signed-out'))->name('hosting.signed-out');
|
||||
|
||||
Route::middleware(['auth'])->group(function () use ($serversApp) {
|
||||
Route::get('/dashboard', [HostingProductController::class, 'index'])->name('hosting.dashboard');
|
||||
Route::get('/dashboard', [OverviewController::class, 'index'])->name('hosting.dashboard');
|
||||
|
||||
Route::get('/hosting', [HostingProductController::class, 'index'])->name('hosting.index');
|
||||
Route::redirect('/hosting', '/dashboard')->name('hosting.index');
|
||||
Route::get('/hosting/accounts', [OverviewController::class, 'accounts'])->name('hosting.accounts.index');
|
||||
Route::get('/hosting/single-domain', [HostingProductController::class, 'singleDomain'])->name('hosting.single-domain');
|
||||
Route::get('/hosting/multi-domain', [HostingProductController::class, 'multiDomain'])->name('hosting.multi-domain');
|
||||
Route::get('/hosting/wordpress', [HostingProductController::class, 'wordpress'])->name('hosting.wordpress');
|
||||
|
||||
Reference in New Issue
Block a user