Commit 4cf0a477 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add client portal, rollback support, portal link sharing

- Client portal: public page for clients (auth by instance UUID)
  Shows instance status, health, billing history, plan info
- Rollback: redeploy previous version from deploy history
- Portal link: copy-to-clipboard button on instance detail page
- Portal layout: minimal, clean, no auth required
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ccb4028c
<?php
namespace App\Livewire;
use App\Models\Instance;
use App\Services\HealthCheckService;
use Livewire\Component;
class ClientPortal extends Component
{
public Instance $instance;
public array $healthStatus = [];
public string $token = '';
public function mount(string $token): void
{
$instance = Instance::where('uuid', $token)
->whereIn('status', ['active', 'trial', 'suspended'])
->firstOrFail();
$this->instance = $instance;
$this->token = $token;
$this->healthStatus = app(HealthCheckService::class)->check($instance);
}
public function refreshHealth(): void
{
$this->healthStatus = app(HealthCheckService::class)->check($this->instance);
}
public function render()
{
$this->instance->load(['plan', 'billingPeriods' => function ($q) {
$q->orderByDesc('period_start')->limit(6);
}]);
return view('livewire.client-portal')
->layout('layouts.portal', ['title' => $this->instance->academy_name_en]);
}
}
......@@ -212,6 +212,36 @@ public function toggleMaintenance(): void
}
}
public function rollback(int $version): void
{
try {
$caprover = app(CapRoverService::class);
$appDef = $caprover->getAppDefinition($this->instance->app_name);
$versions = $appDef['versions'] ?? [];
$target = collect($versions)->firstWhere('version', $version);
if (!$target || empty($target['deployedImageName'])) {
session()->flash('error', 'Cannot rollback: version not found or has no image.');
return;
}
$envMap = collect($appDef['envVars'] ?? [])->pluck('value', 'key')->toArray();
$caprover->configureAppFull($this->instance->app_name, [
'containerHttpPort' => $appDef['containerHttpPort'] ?? 80,
'instanceCount' => $appDef['instanceCount'] ?? 1,
'envVars' => $envMap,
]);
$caprover->deployImage($this->instance->app_name, $target['deployedImageName']);
AuditLog::record('rollback', $this->instance, ['to_version' => $version]);
session()->flash('success', "Rolled back to v{$version}.");
$this->loadState();
} catch (\Throwable $e) {
session()->flash('error', 'Rollback failed: ' . $e->getMessage());
}
}
public function suspend(): void
{
app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard');
......
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'Client Portal' }} — El Captain</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>body { font-family: 'Inter', system-ui, sans-serif; }</style>
@livewireStyles
</head>
<body class="h-full bg-gray-50">
<div class="min-h-full">
<header class="bg-white border-b border-gray-200 px-4 sm:px-6 py-4">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<span class="font-semibold text-gray-900">{{ $title ?? 'Client Portal' }}</span>
</div>
<span class="text-sm text-gray-500">El Captain Platform</span>
</div>
</header>
<main class="max-w-4xl mx-auto p-4 sm:p-6">
{{ $slot }}
</main>
<footer class="max-w-4xl mx-auto px-4 sm:px-6 py-8 text-center text-sm text-gray-400">
El Captain Sports Management Platform
</footer>
</div>
@livewireScripts
</body>
</html>
<div>
{{-- Status Banner --}}
<div class="mb-6 p-4 rounded-xl border {{ $instance->status === 'active' ? 'bg-green-50 border-green-200' : ($instance->status === 'trial' ? 'bg-yellow-50 border-yellow-200' : 'bg-red-50 border-red-200') }}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
@php
$hColors = ['healthy' => 'bg-green-500', 'degraded' => 'bg-yellow-500', 'down' => 'bg-red-500'];
@endphp
<span class="relative flex h-3 w-3">
@if(($healthStatus['status'] ?? '') === 'healthy')
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
@endif
<span class="relative inline-flex rounded-full h-3 w-3 {{ $hColors[$healthStatus['status'] ?? ''] ?? 'bg-gray-400' }}"></span>
</span>
<div>
<p class="font-semibold text-gray-900">{{ $instance->academy_name_ar }}</p>
<p class="text-sm text-gray-600">{{ $instance->academy_name_en }}</p>
</div>
</div>
<div class="text-right">
@php
$statusLabels = ['active' => 'Active', 'trial' => 'Trial', 'suspended' => 'Suspended'];
$statusColors = ['active' => 'bg-green-100 text-green-700', 'trial' => 'bg-yellow-100 text-yellow-700', 'suspended' => 'bg-red-100 text-red-700'];
@endphp
<span class="inline-block px-3 py-1 text-sm font-medium rounded-full {{ $statusColors[$instance->status] ?? 'bg-gray-100 text-gray-700' }}">
{{ $statusLabels[$instance->status] ?? $instance->status }}
</span>
@if(!empty($healthStatus['latency_ms']))
<p class="text-xs text-gray-500 mt-1">Response: {{ $healthStatus['latency_ms'] }}ms</p>
@endif
</div>
</div>
@if($instance->status === 'trial' && $instance->trial_ends_at)
<p class="mt-2 text-sm {{ $instance->trial_ends_at->isPast() ? 'text-red-600 font-medium' : 'text-yellow-700' }}">
Trial {{ $instance->trial_ends_at->isPast() ? 'expired' : 'ends' }} {{ $instance->trial_ends_at->format('M d, Y') }}
@if(!$instance->trial_ends_at->isPast())
({{ $instance->trial_ends_at->diffInDays(now()) }} days remaining)
@endif
</p>
@endif
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Your Instance</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">URL</dt>
<dd><a href="{{ $instance->url }}" target="_blank" class="text-blue-600 hover:underline font-mono text-xs">{{ $instance->url }}</a></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Plan</dt>
<dd class="font-medium text-gray-900">{{ $instance->plan->name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Admin Email</dt>
<dd class="font-medium text-gray-900">{{ $instance->admin_email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Since</dt>
<dd class="font-medium text-gray-900">{{ $instance->created_at->format('M d, Y') }}</dd>
</div>
</dl>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Billing</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Monthly Fee</dt>
<dd class="font-medium text-gray-900">{{ number_format($instance->monthly_fee / 100, 2) }} EGP</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Platform Fee</dt>
<dd class="font-medium text-gray-900">{{ $instance->platform_fee_percent }}% of transactions</dd>
</div>
</dl>
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
<p class="text-xs text-blue-700">For billing inquiries, contact your account manager.</p>
</div>
</div>
</div>
{{-- Billing History --}}
@if($instance->billingPeriods->count())
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900">Billing History</h3>
</div>
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-500">Period</th>
<th class="text-right px-4 py-2.5 font-medium text-gray-500">Amount</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($instance->billingPeriods as $period)
<tr>
<td class="px-4 py-2.5 text-gray-600">
{{ $period->period_start->format('M d') }} - {{ $period->period_end->format('M d, Y') }}
</td>
<td class="px-4 py-2.5 text-right font-medium text-gray-900">
{{ number_format($period->total_due / 100, 2) }} EGP
</td>
<td class="px-4 py-2.5">
@php
$pColors = ['paid' => 'bg-green-100 text-green-700', 'pending' => 'bg-yellow-100 text-yellow-700', 'partially_paid' => 'bg-blue-100 text-blue-700', 'overdue' => 'bg-red-100 text-red-700'];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $pColors[$period->status] ?? 'bg-gray-100 text-gray-600' }}">
{{ str_replace('_', ' ', $period->status) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{-- Health Check --}}
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">System Status</h3>
<button wire:click="refreshHealth" wire:loading.attr="disabled" wire:target="refreshHealth"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
<svg wire:loading.remove wire:target="refreshHealth" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<svg wire:loading wire:target="refreshHealth" class="w-3.5 h-3.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 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Refresh
</button>
</div>
<div class="grid grid-cols-3 gap-4 text-center">
<div class="p-3 rounded-lg {{ ($healthStatus['status'] ?? '') === 'healthy' ? 'bg-green-50' : (($healthStatus['status'] ?? '') === 'down' ? 'bg-red-50' : 'bg-yellow-50') }}">
<p class="text-lg font-bold {{ ($healthStatus['status'] ?? '') === 'healthy' ? 'text-green-600' : (($healthStatus['status'] ?? '') === 'down' ? 'text-red-600' : 'text-yellow-600') }}">
{{ ucfirst($healthStatus['status'] ?? 'Unknown') }}
</p>
<p class="text-xs text-gray-500 mt-1">Status</p>
</div>
<div class="p-3 rounded-lg bg-gray-50">
<p class="text-lg font-bold text-gray-700">{{ $healthStatus['http_code'] ?? '—' }}</p>
<p class="text-xs text-gray-500 mt-1">HTTP Code</p>
</div>
<div class="p-3 rounded-lg bg-gray-50">
<p class="text-lg font-bold text-gray-700">{{ isset($healthStatus['latency_ms']) ? $healthStatus['latency_ms'] . 'ms' : '—' }}</p>
<p class="text-xs text-gray-500 mt-1">Response Time</p>
</div>
</div>
</div>
</div>
......@@ -254,13 +254,22 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gra
<div class="mt-4">
<h4 class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Recent Deploys</h4>
<div class="space-y-1">
@foreach(array_reverse($appState['versions']) as $version)
@foreach(array_reverse($appState['versions']) as $idx => $version)
<div class="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
<span class="font-mono text-gray-600">v{{ $version['version'] ?? '?' }}</span>
<span class="text-gray-500">{{ isset($version['timeStamp']) ? \Carbon\Carbon::parse($version['timeStamp'])->diffForHumans() : '' }}</span>
<div class="flex items-center gap-2">
<span class="inline-block px-1.5 py-0.5 rounded font-medium {{ ($version['deployedImageName'] ?? '') ? 'bg-blue-50 text-blue-600' : 'bg-gray-100 text-gray-500' }}">
{{ ($version['gitHash'] ?? '') ? substr($version['gitHash'], 0, 7) : 'image' }}
</span>
@if($idx > 0 && !empty($version['deployedImageName']))
<button wire:click="rollback({{ $version['version'] }})"
wire:confirm="Rollback to v{{ $version['version'] }}? This will redeploy the older image."
class="px-1.5 py-0.5 rounded text-orange-600 hover:bg-orange-50 font-medium" title="Rollback to this version">
Rollback
</button>
@endif
</div>
</div>
@endforeach
</div>
......@@ -522,6 +531,25 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gra
</div>
@endif
{{-- Client Portal Link --}}
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900">Client Portal</h3>
<p class="text-xs text-gray-500 mt-1">Share this link with the client for self-service access (no login required).</p>
</div>
<div class="flex items-center gap-2" x-data="{ copied: false }">
<input type="text" readonly value="{{ route('portal.show', $instance->uuid) }}"
class="w-72 rounded-lg border-gray-200 bg-gray-50 text-xs font-mono px-3 py-1.5">
<button @click="navigator.clipboard.writeText('{{ route('portal.show', $instance->uuid) }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak class="text-green-600">Copied!</span>
</button>
</div>
</div>
</div>
{{-- Back link --}}
<div class="mt-6">
<a href="{{ route('instances.index') }}" wire:navigate class="text-sm text-gray-500 hover:text-gray-700">&larr; Back to instances</a>
......
......@@ -3,6 +3,7 @@
use App\Livewire\AuditLogList;
use App\Livewire\BillingOverview;
use App\Livewire\ClientList;
use App\Livewire\ClientPortal;
use App\Livewire\Dashboard;
use App\Livewire\DeployInstanceWizard;
use App\Livewire\InstanceList;
......@@ -37,6 +38,9 @@
Route::get('/audit', AuditLogList::class)->name('audit.index');
});
// Client portal — public, authenticated by instance UUID token
Route::get('/portal/{token}', ClientPortal::class)->name('portal.show');
// Webhook endpoint for instance self-reports (no auth, uses token)
Route::post('/api/webhook/fee-report', [\App\Http\Controllers\FeeWebhookController::class, 'store'])
->name('api.fee-report');
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment