Update pricing UX, billing flows, and API rules
This commit is contained in:
64
app/resources/views/dashboard/admin/audit-logs.blade.php
Normal file
64
app/resources/views/dashboard/admin/audit-logs.blade.php
Normal file
@@ -0,0 +1,64 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Audit Logs')
|
||||
@section('page_subtitle', 'Track administrative actions and changes.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Audit trail</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Recent admin actions</div>
|
||||
</div>
|
||||
<form method="GET" class="flex w-full flex-wrap gap-3 md:w-auto md:justify-end">
|
||||
<input type="search" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Search email or action"
|
||||
class="w-full md:w-72 rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface">
|
||||
<select name="action" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All actions</option>
|
||||
@foreach ($actions ?? [] as $action)
|
||||
<option value="{{ $action }}" @selected(($filters['action'] ?? '') === $action)>{{ $action }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase tracking-[0.15em] text-gray-400">
|
||||
<tr>
|
||||
<th class="py-3 pr-4">Admin</th>
|
||||
<th class="py-3 pr-4">Action</th>
|
||||
<th class="py-3 pr-4">IP</th>
|
||||
<th class="py-3 pr-4">Time</th>
|
||||
<th class="py-3 pr-4">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-gray-300">
|
||||
@forelse ($logs ?? [] as $log)
|
||||
<tr>
|
||||
<td class="py-4 pr-4">
|
||||
<div class="font-semibold text-white">{{ $log->admin_email ?? '—' }}</div>
|
||||
<div class="text-xs text-gray-400">#{{ $log->admin_id ?? 'n/a' }}</div>
|
||||
</td>
|
||||
<td class="py-4 pr-4">{{ $log->action }}</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $log->ip_address ?? '—' }}</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $log->created_at?->toDateTimeString() ?? '—' }}</td>
|
||||
<td class="py-4 pr-4 text-xs">
|
||||
<pre class="whitespace-pre-wrap rounded-xl border border-slate-900/60 bg-slate-950 px-3 py-2 text-slate-100 dark:border-black/30 dark:bg-black/70">{{ json_encode($log->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-center text-sm text-gray-400">No audit logs yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $logs->links('vendor.pagination.dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
145
app/resources/views/dashboard/admin/pricing.blade.php
Normal file
145
app/resources/views/dashboard/admin/pricing.blade.php
Normal file
@@ -0,0 +1,145 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Admin Pricing')
|
||||
@section('page_subtitle', 'Manage plan pricing and rollout changes.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
['label' => 'Plans', 'value' => number_format(($plans ?? collect())->count()), 'note' => 'Active tiers'],
|
||||
['label' => 'Pending changes', 'value' => number_format(($changes ?? collect())->count()), 'note' => 'Latest snapshots'],
|
||||
['label' => 'Currency', 'value' => optional(($plans ?? collect())->first())->currency ?? 'IDR', 'note' => 'Default'],
|
||||
] as $card)
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">{{ $card['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $card['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $card['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('dashboard.admin.pricing.update') }}" class="mt-8 grid gap-6 lg:grid-cols-3" data-loading-form>
|
||||
@csrf
|
||||
@forelse ($plans ?? [] as $plan)
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-semibold uppercase tracking-[0.2em] text-gray-400">{{ $plan->code }}</div>
|
||||
<span class="rounded-full bg-slate-200 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-white/10 dark:text-gray-200">{{ $plan->status }}</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 text-sm text-gray-300">
|
||||
<label class="block">
|
||||
Name
|
||||
<input name="plans[{{ $loop->index }}][name]" value="{{ $plan->name }}" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface">
|
||||
</label>
|
||||
<label class="block">
|
||||
Code
|
||||
<input name="plans[{{ $loop->index }}][code]" value="{{ $plan->code }}" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface">
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
IDR price
|
||||
<input name="plans[{{ $loop->index }}][amount_idr]" type="number" value="{{ $plan->amount }}" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface">
|
||||
</label>
|
||||
<label class="block">
|
||||
USD price
|
||||
<input name="plans[{{ $loop->index }}][amount_usd]" type="number" step="0.01" value="{{ $plan->meta['prices']['USD'] ?? '' }}" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="e.g. 9.99">
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
Period
|
||||
<select name="plans[{{ $loop->index }}][period]" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 theme-surface">
|
||||
<option value="" @selected(empty($plan->period))>None</option>
|
||||
<option value="month" @selected($plan->period === 'month')>Monthly</option>
|
||||
<option value="year" @selected($plan->period === 'year')>Annual</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
Status
|
||||
<select name="plans[{{ $loop->index }}][status]" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 theme-surface">
|
||||
<option value="active" @selected($plan->status === 'active')>Active</option>
|
||||
<option value="inactive" @selected($plan->status === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 px-3 py-2 text-xs text-gray-400 theme-surface">
|
||||
<div class="font-semibold text-gray-300">PayPal Plan IDs</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div>Sandbox: <span class="text-gray-200">{{ $plan->meta['paypal']['sandbox']['plan']['id'] ?? '—' }}</span></div>
|
||||
<div>Live: <span class="text-gray-200">{{ $plan->meta['paypal']['live']['plan']['id'] ?? '—' }}</span></div>
|
||||
<div>Last sync: <span class="text-gray-200">{{ $plan->meta['paypal']['live']['plan']['synced_at'] ?? $plan->meta['paypal']['sandbox']['plan']['synced_at'] ?? '—' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-white/5 p-6 text-sm text-gray-400">
|
||||
No pricing plans yet. Use reset to defaults to create base plans.
|
||||
</div>
|
||||
@endforelse
|
||||
<div class="lg:col-span-3 flex flex-wrap gap-3">
|
||||
<button class="rounded-xl bg-white/10 border border-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors" data-loading-btn>Save pricing</button>
|
||||
<button form="pricing-reset" class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors" data-loading-btn>Reset to defaults</button>
|
||||
<button form="pricing-paypal-sync" class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors" data-loading-btn>Sync PayPal plans</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="pricing-reset" method="POST" action="{{ route('dashboard.admin.pricing.reset') }}" data-loading-form>
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<form id="pricing-paypal-sync" method="POST" action="{{ route('dashboard.admin.pricing.paypal_sync') }}" data-loading-form>
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Change log</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Recent pricing updates</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.pricing.snapshot') }}" data-loading-form>
|
||||
@csrf
|
||||
<button class="rounded-xl bg-white/10 border border-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors" data-loading-btn>Create update</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-6 space-y-4 text-sm text-gray-300">
|
||||
@forelse ($changes ?? [] as $change)
|
||||
<div class="flex items-center justify-between rounded-xl border border-white/10 px-4 py-3 theme-surface">
|
||||
<div>
|
||||
<div class="font-semibold text-white">Pricing change #{{ $change->id }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $change->created_at?->toDateString() }} · {{ $change->admin_ref ?? 'system' }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200">Published</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-sm text-gray-400">No pricing changes recorded yet.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('[data-loading-form]');
|
||||
const buttons = document.querySelectorAll('[data-loading-btn]');
|
||||
const setBusy = (on) => {
|
||||
buttons.forEach(btn => {
|
||||
btn.disabled = on;
|
||||
btn.dataset.label = btn.dataset.label || btn.textContent;
|
||||
btn.textContent = on ? 'Processing…' : btn.dataset.label;
|
||||
btn.classList.toggle('opacity-70', on);
|
||||
btn.classList.toggle('cursor-wait', on);
|
||||
});
|
||||
};
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', () => setBusy(true));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
93
app/resources/views/dashboard/admin/settings.blade.php
Normal file
93
app/resources/views/dashboard/admin/settings.blade.php
Normal file
@@ -0,0 +1,93 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Admin Settings')
|
||||
@section('page_subtitle', 'System configuration and feature flags.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
['label' => 'Billing mode', 'value' => ($settings['billing_mode'] ?? config('dewemoji.billing.mode')), 'note' => isset($settings['billing_mode']) ? 'DB' : 'Env'],
|
||||
['label' => 'Rate limit', 'value' => config('dewemoji.rate_limit_enabled') ? 'On' : 'Off', 'note' => 'Global'],
|
||||
['label' => 'Public access', 'value' => ((bool) ($settings['public_enforce'] ?? config('dewemoji.public_access.enforce_whitelist'))) ? 'Whitelist' : 'Open', 'note' => 'Origin policy'],
|
||||
] as $card)
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">{{ $card['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $card['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $card['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Feature flags</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">System toggles</div>
|
||||
<div class="mt-6 space-y-4 text-sm text-gray-300">
|
||||
@foreach ([
|
||||
['label' => 'Maintenance mode', 'desc' => 'Temporarily block public API access', 'state' => ((bool) ($settings['maintenance_enabled'] ?? false)) ? 'On' : 'Off'],
|
||||
['label' => 'Extension verification', 'desc' => 'Require GCM token for extension calls', 'state' => config('dewemoji.extension_verification.enabled') ? 'On' : 'Off'],
|
||||
['label' => 'Metrics', 'desc' => 'Allow internal metrics endpoints', 'state' => config('dewemoji.metrics.enabled') ? 'On' : 'Off'],
|
||||
] as $row)
|
||||
<div class="flex items-center justify-between rounded-xl border border-white/10 px-4 py-3 theme-surface">
|
||||
<div>
|
||||
<div class="font-semibold text-white">{{ $row['label'] }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $row['desc'] }}</div>
|
||||
</div>
|
||||
<span class="rounded-full {{ $row['state'] === 'On' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200' : 'bg-slate-200 text-slate-700 dark:bg-white/10 dark:text-gray-200' }} px-3 py-1 text-xs font-semibold">
|
||||
{{ $row['state'] }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Public access</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Allowlist configuration</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.settings.update') }}" class="mt-5 space-y-4">
|
||||
@csrf
|
||||
<label class="block text-sm text-gray-300">
|
||||
Billing mode
|
||||
<select name="billing_mode" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="sandbox" @selected(($settings['billing_mode'] ?? config('dewemoji.billing.mode')) === 'sandbox')>Sandbox</option>
|
||||
<option value="live" @selected(($settings['billing_mode'] ?? config('dewemoji.billing.mode')) === 'live')>Live</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" name="maintenance_enabled" value="1" class="rounded border-white/20 bg-transparent"
|
||||
@checked((bool) ($settings['maintenance_enabled'] ?? false))>
|
||||
Maintenance mode
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" name="public_enforce" value="1" class="rounded border-white/20 bg-transparent"
|
||||
@checked((bool) ($settings['public_enforce'] ?? config('dewemoji.public_access.enforce_whitelist')))>
|
||||
Enforce whitelist
|
||||
</label>
|
||||
<label class="block text-sm text-gray-300">
|
||||
Allowed origins
|
||||
<textarea name="public_origins" rows="4" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface" placeholder="https://dewemoji.com, https://app.dewemoji.com">{{ isset($settings['public_origins']) ? implode(', ', (array) $settings['public_origins']) : '' }}</textarea>
|
||||
</label>
|
||||
<label class="block text-sm text-gray-300">
|
||||
Extension IDs
|
||||
<input name="public_extension_ids" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface" placeholder="chrome-extension-id, edge-extension-id"
|
||||
value="{{ isset($settings['public_extension_ids']) ? implode(', ', (array) $settings['public_extension_ids']) : '' }}" />
|
||||
</label>
|
||||
<label class="block text-sm text-gray-300">
|
||||
Hourly limit
|
||||
<input name="public_hourly_limit" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface" placeholder="5000"
|
||||
value="{{ $settings['public_hourly_limit'] ?? '' }}" />
|
||||
</label>
|
||||
<button type="submit" class="w-full rounded-xl bg-white/10 border border-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors">
|
||||
Save settings
|
||||
</button>
|
||||
<p class="text-xs text-gray-400">Changes update the settings store immediately.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,65 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Subscription Details')
|
||||
@section('page_subtitle', 'Plan status, provider info, and timeline.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<a href="{{ route('dashboard.admin.subscriptions') }}" class="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-white">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i><span>Back to subscriptions</span>
|
||||
</a>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Subscriber</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $subscription->user?->name ?? '—' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $subscription->user?->email ?? '—' }}</div>
|
||||
@if ($subscription->user)
|
||||
<a href="{{ route('dashboard.admin.users.show', $subscription->user->id) }}" class="mt-3 inline-flex items-center gap-2 text-xs text-brand-ocean hover:text-brand-oceanSoft">
|
||||
<i data-lucide="external-link" class="w-3 h-3"></i><span>View user</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Plan</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $subscription->plan }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Status: {{ $subscription->status }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Provider</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $subscription->provider ?? 'admin' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Ref: {{ $subscription->provider_ref ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Timeline</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Subscription dates</div>
|
||||
<div class="mt-4 space-y-3 text-sm text-gray-300">
|
||||
<div class="flex items-center justify-between rounded-xl border border-white/10 px-4 py-3 theme-surface">
|
||||
<span>Started</span>
|
||||
<span class="text-gray-200">{{ $subscription->started_at?->toDateString() ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-xl border border-white/10 px-4 py-3 theme-surface">
|
||||
<span>Expires</span>
|
||||
<span class="text-gray-200">{{ $subscription->expires_at?->toDateString() ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-xl border border-white/10 px-4 py-3 theme-surface">
|
||||
<span>Created</span>
|
||||
<span class="text-gray-200">{{ $subscription->created_at?->toDateString() ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Actions</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Manage subscription</div>
|
||||
<div class="mt-4 space-y-3 text-sm text-gray-300">
|
||||
<form method="POST" action="{{ route('dashboard.admin.subscriptions.revoke') }}" class="space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="subscription_id" value="{{ $subscription->id }}">
|
||||
<button class="w-full rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Revoke subscription</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
196
app/resources/views/dashboard/admin/subscriptions.blade.php
Normal file
196
app/resources/views/dashboard/admin/subscriptions.blade.php
Normal file
@@ -0,0 +1,196 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Admin Subscriptions')
|
||||
@section('page_subtitle', 'Grant, revoke, and audit Pro access.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
['label' => 'Active', 'value' => number_format(\App\Models\Subscription::where('status', 'active')->count()), 'note' => 'Subscriptions'],
|
||||
['label' => 'Revoked', 'value' => number_format(\App\Models\Subscription::where('status', 'revoked')->count()), 'note' => 'All time'],
|
||||
['label' => 'Pending', 'value' => number_format(\App\Models\Subscription::where('status', 'pending')->count()), 'note' => 'Manual review'],
|
||||
] as $card)
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">{{ $card['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $card['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $card['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Filters</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Refine subscriptions</div>
|
||||
</div>
|
||||
<form method="GET" class="flex w-full flex-wrap gap-3 md:w-auto md:justify-end">
|
||||
<input type="hidden" name="sort" value="{{ $sort ?? 'id' }}">
|
||||
<input type="hidden" name="dir" value="{{ $dir ?? 'desc' }}">
|
||||
<input type="number" name="user_id" value="{{ $filters['user_id'] ?? '' }}" placeholder="User ID"
|
||||
class="w-full md:w-32 rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface">
|
||||
<input type="search" name="email" value="{{ $filters['email'] ?? '' }}" placeholder="User email"
|
||||
class="w-full md:w-64 rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 theme-surface">
|
||||
<select name="status" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All statuses</option>
|
||||
<option value="active" @selected(($filters['status'] ?? '') === 'active')>Active</option>
|
||||
<option value="pending" @selected(($filters['status'] ?? '') === 'pending')>Pending</option>
|
||||
<option value="revoked" @selected(($filters['status'] ?? '') === 'revoked')>Revoked</option>
|
||||
<option value="cancelled" @selected(($filters['status'] ?? '') === 'cancelled')>Cancelled</option>
|
||||
<option value="suspended" @selected(($filters['status'] ?? '') === 'suspended')>Suspended</option>
|
||||
</select>
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Grant access</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Create subscription</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.subscriptions.grant') }}" class="mt-5 grid gap-4 text-sm text-gray-300">
|
||||
@csrf
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
User email
|
||||
<input name="email" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="user@email.com">
|
||||
</label>
|
||||
<label class="block">
|
||||
User ID
|
||||
<input name="user_id" type="number" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="Optional">
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
Plan
|
||||
<input name="plan" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" value="personal">
|
||||
</label>
|
||||
<label class="block">
|
||||
Status
|
||||
<select name="status" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 theme-surface">
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
Started at
|
||||
<input name="started_at" type="date" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 theme-surface">
|
||||
</label>
|
||||
<label class="block">
|
||||
Expires at
|
||||
<input name="expires_at" type="date" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 theme-surface">
|
||||
</label>
|
||||
</div>
|
||||
<button class="rounded-xl bg-white/10 border border-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors">Grant</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Revoke access</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Cancel subscription</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.subscriptions.revoke') }}" class="mt-5 grid gap-4 text-sm text-gray-300">
|
||||
@csrf
|
||||
<label class="block">
|
||||
Subscription ID
|
||||
<input name="subscription_id" type="number" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="Optional">
|
||||
</label>
|
||||
<label class="block">
|
||||
User email
|
||||
<input name="email" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="user@email.com">
|
||||
</label>
|
||||
<label class="block">
|
||||
User ID
|
||||
<input name="user_id" type="number" class="mt-2 w-full rounded-xl border border-white/10 px-3 py-2 text-gray-200 placeholder-gray-500 theme-surface" placeholder="Optional">
|
||||
</label>
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Subscription list</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Recent subscriptions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase tracking-[0.15em] text-gray-400">
|
||||
<tr>
|
||||
@php
|
||||
$sortParam = $sort ?? 'id';
|
||||
$dirParam = $dir ?? 'desc';
|
||||
$toggle = fn ($field) => ($sortParam === $field && $dirParam === 'asc') ? 'desc' : 'asc';
|
||||
$sortUrl = fn ($field) => request()->fullUrlWithQuery(['sort' => $field, 'dir' => $toggle($field)]);
|
||||
@endphp
|
||||
<th class="py-3 pr-4">Subscriber</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('plan') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Plan
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('status') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Status
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('expires_at') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Next billing
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-gray-300">
|
||||
@forelse ($subscriptions ?? [] as $row)
|
||||
<tr>
|
||||
<td class="py-4 pr-4">
|
||||
<div class="font-semibold text-white">{{ $row->user?->name ?? '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $row->user?->email ?? '—' }}</div>
|
||||
</td>
|
||||
<td class="py-4 pr-4">{{ $row->plan }}</td>
|
||||
<td class="py-4 pr-4">
|
||||
@php
|
||||
$inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true);
|
||||
$pill = $inactive
|
||||
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
|
||||
: ($row->status === 'pending'
|
||||
? ['bg' => 'bg-amber-100 dark:bg-amber-500/20', 'text' => 'text-amber-800 dark:text-amber-200']
|
||||
: ['bg' => 'bg-emerald-100 dark:bg-emerald-500/20', 'text' => 'text-emerald-800 dark:text-emerald-200']);
|
||||
@endphp
|
||||
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">
|
||||
{{ $row->status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $row->expires_at?->toDateString() ?? '—' }}</td>
|
||||
<td class="py-4 pr-4 text-right">
|
||||
<a href="{{ route('dashboard.admin.subscriptions.show', $row->id) }}" class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 hover:bg-white/5 transition-colors">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-center text-sm text-gray-400">No subscriptions found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
{{ $subscriptions->links('vendor.pagination.dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
88
app/resources/views/dashboard/admin/user-show.blade.php
Normal file
88
app/resources/views/dashboard/admin/user-show.blade.php
Normal file
@@ -0,0 +1,88 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'User Details')
|
||||
@section('page_subtitle', 'Profile, access, and subscription history.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<a href="{{ route('dashboard.admin.users') }}" class="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-white">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i><span>Back to users</span>
|
||||
</a>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-400">User ID: {{ $user->id }}</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.users.delete', $user->id) }}" data-confirm="Delete this user? This removes their data and cannot be undone." data-confirm-title="Delete user" data-confirm-ok="Delete">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="rounded-full border border-rose-200 px-4 py-2 text-xs font-semibold text-rose-700 hover:bg-rose-50 transition-colors dark:border-rose-500/40 dark:text-rose-200 dark:hover:bg-rose-500/10">Delete user</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Account</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $user->name ?? '—' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $user->email }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Access</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $user->role ?? 'user' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Tier: {{ $user->tier ?? 'free' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Joined</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $user->created_at?->toDateString() ?? '—' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $user->created_at?->diffForHumans() ?? '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Subscriptions</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Recent access records</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase tracking-[0.15em] text-gray-400">
|
||||
<tr>
|
||||
<th class="py-3 pr-4">Plan</th>
|
||||
<th class="py-3 pr-4">Status</th>
|
||||
<th class="py-3 pr-4">Started</th>
|
||||
<th class="py-3 pr-4">Expires</th>
|
||||
<th class="py-3 pr-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-gray-300">
|
||||
@forelse ($subscriptions as $row)
|
||||
<tr>
|
||||
<td class="py-4 pr-4">{{ $row->plan }}</td>
|
||||
<td class="py-4 pr-4">
|
||||
@php
|
||||
$inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true);
|
||||
$pill = $inactive
|
||||
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
|
||||
: ($row->status === 'pending'
|
||||
? ['bg' => 'bg-amber-100 dark:bg-amber-500/20', 'text' => 'text-amber-800 dark:text-amber-200']
|
||||
: ['bg' => 'bg-emerald-100 dark:bg-emerald-500/20', 'text' => 'text-emerald-800 dark:text-emerald-200']);
|
||||
@endphp
|
||||
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">
|
||||
{{ $row->status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $row->started_at?->toDateString() ?? '—' }}</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $row->expires_at?->toDateString() ?? '—' }}</td>
|
||||
<td class="py-4 pr-4 text-right">
|
||||
<a href="{{ route('dashboard.admin.subscriptions.show', $row->id) }}" class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 hover:bg-white/5 transition-colors">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-center text-sm text-gray-400">No subscriptions found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
207
app/resources/views/dashboard/admin/users.blade.php
Normal file
207
app/resources/views/dashboard/admin/users.blade.php
Normal file
@@ -0,0 +1,207 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Admin Users')
|
||||
@section('page_subtitle', 'Manage accounts, roles, and access tiers.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-2xl border border-amber-300/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-200">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
['label' => 'Total users', 'value' => number_format(\App\Models\User::count()), 'note' => 'All accounts'],
|
||||
['label' => 'Personal tier', 'value' => number_format(\App\Models\User::where('tier', 'personal')->count()), 'note' => 'Active subscriptions'],
|
||||
['label' => 'Admins', 'value' => number_format(\App\Models\User::where('role', 'admin')->count()), 'note' => 'Staff'],
|
||||
] as $card)
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">{{ $card['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $card['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $card['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Create</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Add new user</div>
|
||||
</div>
|
||||
<button id="toggle-create-user" type="button" class="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold text-gray-200 hover:bg-white/5">
|
||||
Show form
|
||||
</button>
|
||||
</div>
|
||||
<form id="create-user-form" method="POST" action="{{ route('dashboard.admin.users.create') }}" class="mt-4 grid gap-3 md:grid-cols-2 hidden">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.15em] text-gray-500">Name</label>
|
||||
<input type="text" name="name" placeholder="Optional" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-ocean/40 theme-surface">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.15em] text-gray-500">Email</label>
|
||||
<input type="email" name="email" required class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-ocean/40 theme-surface">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.15em] text-gray-500">Password</label>
|
||||
<input type="password" name="password" minlength="8" required class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-ocean/40 theme-surface">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.15em] text-gray-500">Role</label>
|
||||
<select name="role" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.15em] text-gray-500">Tier</label>
|
||||
<select name="tier" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="free">Free</option>
|
||||
<option value="personal">Personal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button class="w-full rounded-xl bg-brand-ocean text-white font-semibold px-4 py-2 text-sm">Create user</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Directory</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">User list</div>
|
||||
</div>
|
||||
<form method="GET" class="flex w-full flex-wrap gap-3 md:w-auto md:justify-end">
|
||||
<input type="hidden" name="sort" value="{{ $sort ?? 'id' }}">
|
||||
<input type="hidden" name="dir" value="{{ $dir ?? 'desc' }}">
|
||||
<input type="search" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Search by email or name"
|
||||
class="w-full md:w-72 rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-ocean/40 theme-surface">
|
||||
<select name="tier" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All tiers</option>
|
||||
<option value="free" @selected(($filters['tier'] ?? '') === 'free')>Free</option>
|
||||
<option value="personal" @selected(($filters['tier'] ?? '') === 'personal')>Personal</option>
|
||||
</select>
|
||||
<select name="role" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All roles</option>
|
||||
<option value="admin" @selected(($filters['role'] ?? '') === 'admin')>Admin</option>
|
||||
<option value="user" @selected(($filters['role'] ?? '') === 'user')>User</option>
|
||||
</select>
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase tracking-[0.15em] text-gray-400">
|
||||
<tr>
|
||||
@php
|
||||
$sortParam = $sort ?? 'id';
|
||||
$dirParam = $dir ?? 'desc';
|
||||
$toggle = fn ($field) => ($sortParam === $field && $dirParam === 'asc') ? 'desc' : 'asc';
|
||||
$sortUrl = fn ($field) => request()->fullUrlWithQuery(['sort' => $field, 'dir' => $toggle($field)]);
|
||||
@endphp
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('name') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
User
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('role') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Role
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('tier') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Tier
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">Status</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('created_at') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Joined
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-gray-300">
|
||||
@forelse ($users ?? [] as $user)
|
||||
<tr>
|
||||
<td class="py-4 pr-4">
|
||||
<div class="font-semibold text-white">{{ $user->name ?? '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $user->email }}</div>
|
||||
</td>
|
||||
<td class="py-4 pr-4">{{ $user->role ?? 'user' }}</td>
|
||||
<td class="py-4 pr-4">
|
||||
<form method="POST" action="{{ route('dashboard.admin.users.tier') }}" class="flex items-center gap-2">
|
||||
@csrf
|
||||
<input type="hidden" name="user_id" value="{{ $user->id }}">
|
||||
<select name="tier" class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 theme-surface">
|
||||
<option value="free" @selected($user->tier === 'free')>Free</option>
|
||||
<option value="personal" @selected($user->tier === 'personal')>Personal</option>
|
||||
</select>
|
||||
<button class="text-xs font-semibold text-brand-ocean hover:text-brand-oceanSoft">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="py-4 pr-4">
|
||||
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200">Active</span>
|
||||
</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $user->created_at?->toDateString() }}</td>
|
||||
<td class="py-4 pr-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('dashboard.admin.users.show', $user->id) }}" class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 hover:bg-white/5 transition-colors">View</a>
|
||||
<form method="POST" action="{{ route('dashboard.admin.users.delete', $user->id) }}" data-confirm="Delete this user? This removes their data and cannot be undone." data-confirm-title="Delete user" data-confirm-ok="Delete">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="rounded-full border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-50 transition-colors dark:border-rose-500/40 dark:text-rose-200 dark:hover:bg-rose-500/10">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="py-6 text-center text-sm text-gray-400">No users found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
{{ $users->links('vendor.pagination.dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const toggleBtn = document.getElementById('toggle-create-user');
|
||||
const form = document.getElementById('create-user-form');
|
||||
if (!toggleBtn || !form) return;
|
||||
|
||||
const setState = (open) => {
|
||||
form.classList.toggle('hidden', !open);
|
||||
toggleBtn.textContent = open ? 'Hide form' : 'Show form';
|
||||
};
|
||||
|
||||
const hasErrors = @json($errors->any());
|
||||
setState(hasErrors);
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
setState(form.classList.contains('hidden'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
57
app/resources/views/dashboard/admin/webhook-show.blade.php
Normal file
57
app/resources/views/dashboard/admin/webhook-show.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Webhook Detail')
|
||||
@section('page_subtitle', 'Event payload, headers, and processing status.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<a href="{{ route('dashboard.admin.webhooks') }}" class="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-white">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i><span>Back to webhooks</span>
|
||||
</a>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Event</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">#{{ $event->id }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $event->event_type ?? 'event' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Provider</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $event->provider }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Event ID: {{ $event->event_id ?? '—' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Status</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $event->status }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Received: {{ $event->received_at?->toDateTimeString() ?? $event->created_at?->toDateTimeString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Payload</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Event body</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('dashboard.admin.webhooks.replay', $event->id) }}">
|
||||
@csrf
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Replay</button>
|
||||
</form>
|
||||
</div>
|
||||
<pre class="mt-4 rounded-xl border border-white/10 bg-black/40 p-4 text-xs text-gray-200 overflow-x-auto">{{ json_encode($event->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
@if ($event->error)
|
||||
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-200">
|
||||
{{ $event->error }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Headers</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Request metadata</div>
|
||||
<pre class="mt-4 rounded-xl border border-white/10 bg-black/40 p-4 text-xs text-gray-200 overflow-x-auto">{{ json_encode($event->headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
<div class="mt-4 text-sm text-gray-300">
|
||||
Processed: <span class="text-gray-200">{{ $event->processed_at?->toDateTimeString() ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
139
app/resources/views/dashboard/admin/webhooks.blade.php
Normal file
139
app/resources/views/dashboard/admin/webhooks.blade.php
Normal file
@@ -0,0 +1,139 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Admin Webhooks')
|
||||
@section('page_subtitle', 'Monitor deliveries, failures, and replays.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
['label' => 'Events (total)', 'value' => number_format(\App\Models\WebhookEvent::count()), 'note' => 'All providers'],
|
||||
['label' => 'Failures', 'value' => number_format(\App\Models\WebhookEvent::where('status', 'error')->count()), 'note' => 'Needs replay'],
|
||||
['label' => 'Providers', 'value' => number_format(\App\Models\WebhookEvent::distinct('provider')->count('provider')), 'note' => 'Configured'],
|
||||
] as $card)
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">{{ $card['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $card['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $card['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Delivery log</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Recent events</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="GET" class="flex flex-wrap gap-2">
|
||||
<input type="hidden" name="sort" value="{{ $sort ?? 'id' }}">
|
||||
<input type="hidden" name="dir" value="{{ $dir ?? 'desc' }}">
|
||||
<select name="provider" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All providers</option>
|
||||
@foreach ($providers ?? [] as $provider)
|
||||
<option value="{{ $provider }}" @selected(($filters['provider'] ?? '') === $provider)>{{ $provider }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="status" class="rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="">All statuses</option>
|
||||
<option value="processed" @selected(($filters['status'] ?? '') === 'processed')>Processed</option>
|
||||
<option value="received" @selected(($filters['status'] ?? '') === 'received')>Received</option>
|
||||
<option value="pending" @selected(($filters['status'] ?? '') === 'pending')>Pending</option>
|
||||
<option value="error" @selected(($filters['status'] ?? '') === 'error')>Error</option>
|
||||
</select>
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Filter</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('dashboard.admin.webhooks.replay_failed') }}">
|
||||
@csrf
|
||||
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Replay failed</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs uppercase tracking-[0.15em] text-gray-400">
|
||||
<tr>
|
||||
@php
|
||||
$sortParam = $sort ?? 'id';
|
||||
$dirParam = $dir ?? 'desc';
|
||||
$toggle = fn ($field) => ($sortParam === $field && $dirParam === 'asc') ? 'desc' : 'asc';
|
||||
$sortUrl = fn ($field) => request()->fullUrlWithQuery(['sort' => $field, 'dir' => $toggle($field)]);
|
||||
@endphp
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('id') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Event
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('provider') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Provider
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('status') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Status
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4">
|
||||
<a href="{{ $sortUrl('received_at') }}" class="inline-flex items-center gap-1 hover:text-white">
|
||||
Time
|
||||
<i data-lucide="arrow-up-down" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th class="py-3 pr-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10 text-gray-300">
|
||||
@forelse ($events ?? [] as $row)
|
||||
<tr>
|
||||
<td class="py-4 pr-4">
|
||||
<div class="font-semibold text-white">#{{ $row->id }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $row->event_type ?? 'event' }}</div>
|
||||
</td>
|
||||
<td class="py-4 pr-4">{{ $row->provider }}</td>
|
||||
<td class="py-4 pr-4">
|
||||
@php
|
||||
$pill = $row->status === 'error'
|
||||
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
|
||||
: ($row->status === 'pending' || $row->status === 'received'
|
||||
? ['bg' => 'bg-amber-100 dark:bg-amber-500/20', 'text' => 'text-amber-800 dark:text-amber-200']
|
||||
: ['bg' => 'bg-emerald-100 dark:bg-emerald-500/20', 'text' => 'text-emerald-800 dark:text-emerald-200']);
|
||||
@endphp
|
||||
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">
|
||||
{{ $row->status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 pr-4 text-xs">{{ $row->received_at?->diffForHumans() ?? $row->created_at?->diffForHumans() }}</td>
|
||||
<td class="py-4 pr-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('dashboard.admin.webhooks.show', $row->id) }}" class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 hover:bg-white/5 transition-colors">View</a>
|
||||
<form method="POST" action="{{ route('dashboard.admin.webhooks.replay', $row->id) }}">
|
||||
@csrf
|
||||
<button class="rounded-full border border-white/10 px-3 py-1 text-xs font-semibold text-gray-200 hover:bg-white/5 transition-colors">Replay</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-center text-sm text-gray-400">No webhook events found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
{{ $events->links('vendor.pagination.dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user