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
|
||||
286
app/resources/views/dashboard/app.blade.php
Normal file
286
app/resources/views/dashboard/app.blade.php
Normal file
@@ -0,0 +1,286 @@
|
||||
@extends('site.layout')
|
||||
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$isAdmin = Gate::allows('admin');
|
||||
|
||||
$navUser = [
|
||||
['label' => 'Overview', 'route' => 'dashboard.overview', 'icon' => 'layout-dashboard'],
|
||||
['label' => 'My Keywords', 'route' => 'dashboard.keywords', 'icon' => 'hash'],
|
||||
['label' => 'API Keys', 'route' => 'dashboard.api-keys', 'icon' => 'key-round'],
|
||||
['label' => 'Billing', 'route' => 'dashboard.billing', 'icon' => 'badge-dollar-sign'],
|
||||
['label' => 'Preferences', 'route' => 'dashboard.preferences', 'icon' => 'settings-2'],
|
||||
['label' => 'Profile', 'route' => 'profile.edit', 'icon' => 'user-round'],
|
||||
];
|
||||
|
||||
$navAdmin = [
|
||||
['label' => 'Overview', 'route' => 'dashboard.overview', 'icon' => 'layout-dashboard'],
|
||||
['label' => 'Users', 'route' => 'dashboard.admin.users', 'icon' => 'users'],
|
||||
['label' => 'Subscriptions', 'route' => 'dashboard.admin.subscriptions', 'icon' => 'credit-card'],
|
||||
['label' => 'Pricing', 'route' => 'dashboard.admin.pricing', 'icon' => 'badge-dollar-sign'],
|
||||
['label' => 'Webhooks', 'route' => 'dashboard.admin.webhooks', 'icon' => 'webhook'],
|
||||
['label' => 'Audit Logs', 'route' => 'dashboard.admin.audit_logs', 'icon' => 'list-checks'],
|
||||
['label' => 'Settings', 'route' => 'dashboard.admin.settings', 'icon' => 'settings'],
|
||||
['label' => 'Profile', 'route' => 'profile.edit', 'icon' => 'user-round'],
|
||||
];
|
||||
|
||||
$nav = $isAdmin ? $navAdmin : $navUser;
|
||||
$exportQuery = request()->query();
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="flex h-screen w-full">
|
||||
<aside class="hidden lg:flex w-20 lg:w-64 h-full glass-panel flex-col justify-between p-4 z-50 shrink-0">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 px-2 mb-8 mt-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-white to-gray-300 flex items-center justify-center shadow-lg shadow-white/20 shrink-0">
|
||||
<img src="/assets/logo/logo-mark.svg" alt="Dewemoji logo" class="w-7 h-7 object-contain" />
|
||||
</div>
|
||||
<div class="hidden lg:block">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Dewemoji</div>
|
||||
<div class="font-display font-bold text-lg tracking-tight">Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-2 mb-6">
|
||||
<div class="rounded-2xl bg-[#0b0b0f]/90 theme-surface border border-white/5 px-4 py-3">
|
||||
<div class="text-xs text-gray-400">
|
||||
Signed in as {{ $isAdmin ? 'Admin' : 'User' }}
|
||||
</div>
|
||||
<div class="mt-1 font-semibold text-gray-200">{{ $user?->name ?? 'Guest' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $user?->email ?? 'no-session' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="space-y-1">
|
||||
<div class="px-3 text-[11px] uppercase tracking-[0.3em] text-gray-500 mb-2">Menu</div>
|
||||
@foreach ($nav as $item)
|
||||
@php
|
||||
$active = request()->routeIs($item['route']);
|
||||
$classes = $active
|
||||
? 'bg-white/10 text-brand-sun border border-white/5'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border border-transparent';
|
||||
@endphp
|
||||
<a href="{{ route($item['route']) }}" class="flex items-center gap-4 px-3 py-3 rounded-xl transition-all group {{ $classes }}">
|
||||
<i data-lucide="{{ $item['icon'] }}" class="w-5 h-5 group-hover:scale-110 transition-transform"></i>
|
||||
<span class="text-sm font-medium hidden lg:block">{{ $item['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-4 px-3 py-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all">
|
||||
<i data-lucide="layout-grid" class="w-5 h-5"></i>
|
||||
<span class="text-sm font-medium hidden lg:block">Discover</span>
|
||||
</a>
|
||||
<a href="{{ route('support') }}" class="flex items-center gap-4 px-3 py-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all">
|
||||
<i data-lucide="life-buoy" class="w-5 h-5"></i>
|
||||
<span class="text-sm font-medium hidden lg:block">Support</span>
|
||||
</a>
|
||||
@auth
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full flex items-center gap-4 px-3 py-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all">
|
||||
<i data-lucide="log-out" class="w-5 h-5"></i>
|
||||
<span class="text-sm font-medium hidden lg:block">Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
@endauth
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 flex flex-col h-full min-w-0 relative z-10">
|
||||
<header class="glass-header px-6 py-6 shrink-0 sticky top-0 z-40">
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.3em] text-gray-400">Workspace</div>
|
||||
<h1 class="mt-2 text-3xl font-bold">{{ trim($__env->yieldContent('page_title')) ?: 'Dashboard Overview' }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-400">{{ trim($__env->yieldContent('page_subtitle')) ?: 'A shared layout with role-based navigation.' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<div class="relative">
|
||||
<button id="quick-action-btn" class="rounded-full bg-white/10 text-white border border-white/10 px-5 py-2 text-sm font-semibold hover:bg-white/20 transition-colors">
|
||||
Quick action
|
||||
</button>
|
||||
<div id="quick-action-menu" class="hidden absolute right-0 mt-2 w-60 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div class="text-[11px] uppercase tracking-[0.3em] text-slate-500 px-3 py-2 dark:text-gray-500">Actions</div>
|
||||
@if ($isAdmin)
|
||||
<a href="{{ route('dashboard.admin.users') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="users" class="w-4 h-4"></i><span>Manage users</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.subscriptions') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="credit-card" class="w-4 h-4"></i><span>Grant subscription</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.webhooks') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="webhook" class="w-4 h-4"></i><span>Review webhooks</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.audit_logs') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="list-checks" class="w-4 h-4"></i><span>Audit logs</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.settings') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i><span>Update settings</span>
|
||||
</a>
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="user-round" class="w-4 h-4"></i><span>Edit profile</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('dashboard.keywords') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i><span>My keywords</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.keywords') }}#add" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i><span>Add keyword</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.api-keys') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="key-round" class="w-4 h-4"></i><span>Manage API keys</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.billing') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="badge-dollar-sign" class="w-4 h-4"></i><span>Billing overview</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.preferences') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="settings-2" class="w-4 h-4"></i><span>Preferences</span>
|
||||
</a>
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="user-round" class="w-4 h-4"></i><span>Edit profile</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($isAdmin)
|
||||
<div class="relative">
|
||||
<button id="export-btn" class="rounded-full border border-white/10 px-5 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">
|
||||
Export
|
||||
</button>
|
||||
<div id="export-menu" class="hidden absolute right-0 mt-2 w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div class="text-[11px] uppercase tracking-[0.3em] text-slate-500 px-3 py-2 dark:text-gray-500">Export CSV</div>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'users'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="users" class="w-4 h-4"></i><span>Users</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'subscriptions'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="credit-card" class="w-4 h-4"></i><span>Subscriptions</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'webhooks'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="webhook" class="w-4 h-4"></i><span>Webhooks</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<button id="theme-toggle" class="w-10 h-10 rounded-full theme-surface border border-white/10 shadow-lg flex items-center justify-center text-gray-300 hover:text-white transition-colors">
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
<i data-lucide="moon" class="w-4 h-4" data-theme-icon="dark"></i>
|
||||
<i data-lucide="sun" class="w-4 h-4 hidden" data-theme-icon="light"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 pb-24 lg:pb-6">
|
||||
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
|
||||
<div class="mb-6 rounded-2xl border border-amber-300/40 bg-amber-400/10 px-4 py-4 text-sm text-amber-200">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-amber-100">Verify your email to unlock billing and API keys.</div>
|
||||
<div class="mt-1 text-xs text-amber-200/80">No reminders will be sent automatically.</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<button class="rounded-full border border-amber-200/40 px-4 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-200/10 transition-colors">
|
||||
Resend verification
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@yield('dashboard_content')
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('mobile_nav')
|
||||
<nav class="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t border-white/10 bg-[#0b0b0f]/95 backdrop-blur px-2 py-2 theme-nav">
|
||||
<div class="grid grid-cols-4 gap-1 text-[11px]">
|
||||
<a href="{{ route('dashboard.overview') }}" class="flex flex-col items-center justify-center gap-1 rounded-lg py-2 {{ request()->routeIs('dashboard.overview') ? 'text-brand-sun bg-white/5' : 'text-gray-300 hover:bg-white/5' }}">
|
||||
<i data-lucide="layout-dashboard" class="w-4 h-4"></i><span>Overview</span>
|
||||
</a>
|
||||
<a href="{{ $isAdmin ? route('dashboard.admin.users') : route('dashboard.keywords') }}" class="flex flex-col items-center justify-center gap-1 rounded-lg py-2 {{ request()->routeIs('dashboard.admin.users') || request()->routeIs('dashboard.keywords') ? 'text-brand-sun bg-white/5' : 'text-gray-300 hover:bg-white/5' }}">
|
||||
<i data-lucide="{{ $isAdmin ? 'users' : 'hash' }}" class="w-4 h-4"></i><span>{{ $isAdmin ? 'Users' : 'Keywords' }}</span>
|
||||
</a>
|
||||
<a href="{{ $isAdmin ? route('dashboard.admin.pricing') : route('dashboard.billing') }}" class="flex flex-col items-center justify-center gap-1 rounded-lg py-2 {{ request()->routeIs('dashboard.admin.pricing') || request()->routeIs('dashboard.billing') ? 'text-brand-sun bg-white/5' : 'text-gray-300 hover:bg-white/5' }}">
|
||||
<i data-lucide="badge-dollar-sign" class="w-4 h-4"></i><span>Plans</span>
|
||||
</a>
|
||||
<button id="more-menu-btn" class="flex flex-col items-center justify-center gap-1 rounded-lg py-2 text-gray-300 hover:bg-white/5">
|
||||
<i data-lucide="more-horizontal" class="w-4 h-4"></i><span>More</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@endsection
|
||||
|
||||
@section('more_menu')
|
||||
<div id="more-menu-backdrop" class="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 hidden"></div>
|
||||
<div id="more-menu" class="lg:hidden fixed bottom-16 left-4 right-4 glass-panel rounded-2xl p-4 z-50 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-semibold">More</span>
|
||||
<button id="more-menu-close" class="w-8 h-8 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
@foreach ($nav as $item)
|
||||
<a href="{{ route($item['route']) }}" class="flex items-center gap-3 rounded-xl px-4 py-3 bg-white/5 hover:bg-white/10">
|
||||
<i data-lucide="{{ $item['icon'] }}" class="w-4 h-4"></i><span>{{ $item['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 bg-white/5 hover:bg-white/10">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i><span>Discover</span>
|
||||
</a>
|
||||
@auth
|
||||
<form method="POST" action="{{ route('logout') }}" class="flex">
|
||||
@csrf
|
||||
<button type="submit" class="flex items-center gap-3 rounded-xl px-4 py-3 bg-white/5 hover:bg-white/10 w-full text-left">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i><span>Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const quickBtn = document.getElementById('quick-action-btn');
|
||||
const quickMenu = document.getElementById('quick-action-menu');
|
||||
const exportBtn = document.getElementById('export-btn');
|
||||
const exportMenu = document.getElementById('export-menu');
|
||||
|
||||
const closeMenus = () => {
|
||||
if (quickMenu) quickMenu.classList.add('hidden');
|
||||
if (exportMenu) exportMenu.classList.add('hidden');
|
||||
};
|
||||
|
||||
if (quickBtn && quickMenu) {
|
||||
quickBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const open = !quickMenu.classList.contains('hidden');
|
||||
closeMenus();
|
||||
if (!open) quickMenu.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (exportBtn && exportMenu) {
|
||||
exportBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const open = !exportMenu.classList.contains('hidden');
|
||||
closeMenus();
|
||||
if (!open) exportMenu.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeMenus);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenus(); });
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
182
app/resources/views/dashboard/index.blade.php
Normal file
182
app/resources/views/dashboard/index.blade.php
Normal file
@@ -0,0 +1,182 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
@section('page_title', 'Dashboard Overview')
|
||||
@section('page_subtitle', 'A shared layout with role-based navigation.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@php
|
||||
$isAdmin = Gate::allows('admin');
|
||||
$metrics = $overviewMetrics ?? [
|
||||
'users_total' => 0,
|
||||
'users_personal' => 0,
|
||||
'subscriptions_active' => 0,
|
||||
'subscriptions_total' => 0,
|
||||
'webhook_total' => 0,
|
||||
'webhook_errors' => 0,
|
||||
];
|
||||
$personalPct = $metrics['users_total'] > 0
|
||||
? round(($metrics['users_personal'] / $metrics['users_total']) * 100)
|
||||
: 0;
|
||||
@endphp
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Total users</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($metrics['users_total']) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ number_format($metrics['users_personal']) }} personal users</div>
|
||||
<div class="mt-4 h-2 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div class="h-full rounded-full bg-brand-ocean" style="width: {{ $personalPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Active subscriptions</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($metrics['subscriptions_active']) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ number_format($metrics['subscriptions_total']) }} total subscriptions</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm text-brand-sun">
|
||||
<span class="inline-flex h-2 w-2 rounded-full bg-brand-sun"></span>
|
||||
{{ $metrics['subscriptions_active'] > 0 ? 'Live access enabled' : 'No active subs' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Webhook events</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($metrics['webhook_total']) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ number_format($metrics['webhook_errors']) }} failures</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm text-gray-400">
|
||||
<span class="inline-flex h-2 w-2 rounded-full {{ $metrics['webhook_errors'] > 0 ? 'bg-amber-400' : 'bg-emerald-400' }}"></span>
|
||||
{{ $metrics['webhook_errors'] > 0 ? 'Needs review' : 'All clear' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Insights</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Usage highlights</div>
|
||||
</div>
|
||||
<span class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-300 theme-surface">Last 7 days</span>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl theme-surface border border-white/10 p-4 shadow-sm">
|
||||
<div class="text-sm font-semibold text-gray-200">Top search</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-white">"smile"</div>
|
||||
<div class="mt-1 text-xs text-gray-400">14% of queries</div>
|
||||
</div>
|
||||
<div class="rounded-2xl theme-surface border border-white/10 p-4 shadow-sm">
|
||||
<div class="text-sm font-semibold text-gray-200">Most used category</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-white">Smileys</div>
|
||||
<div class="mt-1 text-xs text-gray-400">8,214 views</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 rounded-2xl border border-white/10 theme-surface p-4 shadow-sm">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">New users</div>
|
||||
<div class="mt-2 text-sm text-gray-300">Last 7 days</div>
|
||||
<div class="mt-4">
|
||||
<div id="overview-users-chart" class="h-36"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-white/10 theme-surface p-4 shadow-sm">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Subscriptions</div>
|
||||
<div class="mt-2 text-sm text-gray-300">Created last 7 days</div>
|
||||
<div class="mt-3">
|
||||
<div id="overview-subs-chart" class="h-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 theme-surface p-4 shadow-sm">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Webhooks</div>
|
||||
<div class="mt-2 text-sm text-gray-300">Events last 7 days</div>
|
||||
<div class="mt-3">
|
||||
<div id="overview-webhooks-chart" class="h-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const waitForApex = (cb) => {
|
||||
if (window.ApexCharts) return cb();
|
||||
let tries = 0;
|
||||
const t = setInterval(() => {
|
||||
if (window.ApexCharts) {
|
||||
clearInterval(t);
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
tries += 1;
|
||||
if (tries > 50) {
|
||||
clearInterval(t);
|
||||
console.warn('ApexCharts not loaded.');
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
const isDark = () => root.classList.contains('dark');
|
||||
const palette = () => ({
|
||||
text: isDark() ? '#e2e8f0' : '#334155',
|
||||
grid: isDark() ? 'rgba(148,163,184,0.15)' : 'rgba(15,23,42,0.08)',
|
||||
blue: isDark() ? '#60a5fa' : '#2563eb',
|
||||
amber: isDark() ? '#f59e0b' : '#d97706',
|
||||
mint: isDark() ? '#34d399' : '#10b981',
|
||||
});
|
||||
|
||||
const labels = @json($chartLabels ?? []);
|
||||
const users = @json($chartValues ?? []);
|
||||
const subs = @json($chartSubs ?? []);
|
||||
const webhooks = @json($chartWebhooks ?? []);
|
||||
|
||||
waitForApex(() => {
|
||||
const colors = palette();
|
||||
const base = {
|
||||
chart: {
|
||||
type: 'area',
|
||||
sparkline: { enabled: true },
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true }
|
||||
},
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: 0.25 },
|
||||
grid: { show: false },
|
||||
tooltip: { theme: isDark() ? 'dark' : 'light' },
|
||||
xaxis: { categories: labels },
|
||||
yaxis: { show: false }
|
||||
};
|
||||
|
||||
const usersEl = document.getElementById('overview-users-chart');
|
||||
if (usersEl) {
|
||||
new ApexCharts(usersEl, {
|
||||
...base,
|
||||
colors: [colors.blue],
|
||||
series: [{ name: 'New users', data: users }]
|
||||
}).render();
|
||||
}
|
||||
|
||||
const subsEl = document.getElementById('overview-subs-chart');
|
||||
if (subsEl) {
|
||||
new ApexCharts(subsEl, {
|
||||
...base,
|
||||
colors: [colors.amber],
|
||||
series: [{ name: 'Subscriptions', data: subs }]
|
||||
}).render();
|
||||
}
|
||||
|
||||
const webhooksEl = document.getElementById('overview-webhooks-chart');
|
||||
if (webhooksEl) {
|
||||
new ApexCharts(webhooksEl, {
|
||||
...base,
|
||||
colors: [colors.mint],
|
||||
series: [{ name: 'Webhooks', data: webhooks }]
|
||||
}).render();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
164
app/resources/views/dashboard/layout.blade.php
Normal file
164
app/resources/views/dashboard/layout.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@yield('title', 'Dashboard') · Dewemoji</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="preload" href="/assets/fonts/PlusJakartaSans-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||
display: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
ink: '#0b0b10',
|
||||
paper: '#f7f6f2',
|
||||
dune: '#e7e1d6',
|
||||
ocean: '#1d4ed8',
|
||||
ember: '#f97316',
|
||||
},
|
||||
boxShadow: {
|
||||
'panel': '0 18px 60px rgba(15, 23, 42, 0.12)',
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Plus Jakarta Sans";
|
||||
src: url("/assets/fonts/PlusJakartaSans-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Plus Jakarta Sans";
|
||||
src: url("/assets/fonts/PlusJakartaSans-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Plus Jakarta Sans";
|
||||
src: url("/assets/fonts/PlusJakartaSans-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Plus Jakarta Sans";
|
||||
src: url("/assets/fonts/PlusJakartaSans-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-paper text-slate-900">
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$isAdmin = Gate::allows('admin');
|
||||
|
||||
$navUser = [
|
||||
['label' => 'Overview', 'href' => route('dashboard.overview')],
|
||||
['label' => 'Usage', 'href' => route('dashboard.usage')],
|
||||
['label' => 'API Keys', 'href' => route('dashboard.api-keys')],
|
||||
['label' => 'Billing', 'href' => route('dashboard.billing')],
|
||||
['label' => 'Preferences', 'href' => route('dashboard.preferences')],
|
||||
];
|
||||
$navAdmin = [
|
||||
['label' => 'Overview', 'href' => route('dashboard.overview')],
|
||||
['label' => 'Users', 'href' => route('dashboard.admin.users')],
|
||||
['label' => 'Subscriptions', 'href' => route('dashboard.admin.subscriptions')],
|
||||
['label' => 'Pricing', 'href' => route('dashboard.admin.pricing')],
|
||||
['label' => 'Webhooks', 'href' => route('dashboard.admin.webhooks')],
|
||||
['label' => 'Settings', 'href' => route('dashboard.admin.settings')],
|
||||
];
|
||||
$nav = $isAdmin ? $navAdmin : $navUser;
|
||||
@endphp
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-[#f8f5ef] via-[#f6f7fb] to-[#e8eefc]">
|
||||
<div class="mx-auto flex max-w-7xl flex-col lg:flex-row lg:items-start lg:gap-6">
|
||||
<aside class="w-full lg:w-72 lg:shrink-0 lg:self-start">
|
||||
<div class="p-6 lg:sticky lg:top-6">
|
||||
<div class="rounded-3xl bg-white shadow-panel border border-[#e8e3d7] p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-400">Dewemoji</div>
|
||||
<div class="text-2xl font-semibold text-slate-900">Dashboard</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-ink text-paper text-xs px-3 py-1">
|
||||
{{ $isAdmin ? 'Admin' : 'User' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl bg-[#0f172a] text-white p-4">
|
||||
<div class="text-sm opacity-70">Signed in as</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ $user?->name ?? 'Guest' }}
|
||||
</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ $user?->email ?? 'no-session' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mt-6">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400">Menu</div>
|
||||
<ul class="mt-4 space-y-2">
|
||||
@foreach ($nav as $item)
|
||||
<li>
|
||||
<a href="{{ $item['href'] }}"
|
||||
class="flex items-center justify-between rounded-xl border border-transparent px-3 py-2 text-sm font-medium text-slate-700 transition hover:border-[#d9d3c7] hover:bg-[#f5f1ea]">
|
||||
<span>{{ $item['label'] }}</span>
|
||||
<span class="text-xs text-slate-400">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-[#d8d2c6] bg-[#faf7f1] p-4 text-sm text-slate-600">
|
||||
<div class="font-semibold text-slate-800">Tip</div>
|
||||
<div class="mt-1">Menu items are shared layout; only the sidebar changes by role.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0 p-6 lg:p-10">
|
||||
<div class="rounded-[32px] border border-[#e2deca] bg-white/70 shadow-panel backdrop-blur">
|
||||
<div class="border-b border-[#ece7db] p-6 lg:p-8">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-slate-400">Workspace</div>
|
||||
<h1 class="mt-2 text-3xl font-semibold text-slate-900">
|
||||
@yield('page_title', 'Dashboard Overview')
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">@yield('page_subtitle', 'Everything you need in one calm, focused place.') </p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="rounded-full bg-ink px-5 py-2 text-sm font-semibold text-paper">
|
||||
Quick action
|
||||
</button>
|
||||
<button class="rounded-full border border-[#d9d3c7] px-5 py-2 text-sm font-semibold text-slate-700">
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 lg:p-8">
|
||||
@yield('content')
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
42
app/resources/views/dashboard/page.blade.php
Normal file
42
app/resources/views/dashboard/page.blade.php
Normal file
@@ -0,0 +1,42 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', $title)
|
||||
@section('page_title', $title)
|
||||
@section('page_subtitle', $subtitle)
|
||||
|
||||
@section('dashboard_content')
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
@foreach ($kpis as $kpi)
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">{{ $kpi['label'] }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $kpi['value'] }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $kpi['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Summary</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">{{ $summaryTitle }}</div>
|
||||
</div>
|
||||
<span class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-300 theme-surface">{{ $summaryTag }}</span>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-300">{{ $summaryBody }}</p>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
@foreach ($highlights as $highlight)
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="text-sm font-semibold text-gray-200">{{ $highlight['title'] }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-white">{{ $highlight['value'] }}</div>
|
||||
<div class="mt-1 text-xs text-gray-400">{{ $highlight['note'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-white/10 p-4 text-sm text-gray-300">
|
||||
{{ $footerNote }}
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
117
app/resources/views/dashboard/user/api-keys.blade.php
Normal file
117
app/resources/views/dashboard/user/api-keys.blade.php
Normal file
@@ -0,0 +1,117 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'API Keys')
|
||||
@section('page_title', 'API Keys')
|
||||
@section('page_subtitle', 'Create and manage access tokens for integrations.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Your keys</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Manage API access</div>
|
||||
</div>
|
||||
@if ($canCreate)
|
||||
<form method="POST" action="{{ route('dashboard.api-keys.create') }}" class="flex items-center gap-2">
|
||||
@csrf
|
||||
<input type="text" name="name" placeholder="Key name (optional)" class="rounded-full bg-white/5 border border-white/10 px-4 py-2 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean">
|
||||
<button type="submit" class="rounded-full bg-brand-ocean text-white font-semibold px-4 py-2 text-sm">Create key</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@if (!$canCreate)
|
||||
<div class="mt-4 rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 text-sm text-amber-200">
|
||||
API keys are available on the Personal plan. Upgrade to unlock API access.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($newKey)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-400/30 bg-emerald-400/10 p-4 text-sm text-emerald-200">
|
||||
New key created. Copy it now; you won’t be able to see it again.
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<code class="rounded-lg bg-black/40 px-3 py-2 text-xs text-emerald-200">{{ $newKey }}</code>
|
||||
<button type="button" data-copy-key="{{ $newKey }}" class="rounded-full border border-emerald-400/40 px-3 py-1 text-xs text-emerald-200 hover:bg-emerald-400/10">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full text-sm text-gray-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-gray-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Prefix</th>
|
||||
<th class="px-4 py-3 text-left">Name</th>
|
||||
<th class="px-4 py-3 text-left">Created</th>
|
||||
<th class="px-4 py-3 text-left">Last used</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($keys as $key)
|
||||
<tr class="border-t border-white/5">
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-400">{{ $key->key_prefix }}</td>
|
||||
<td class="px-4 py-3 text-white">{{ $key->name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-400">{{ $key->created_at?->toDateString() }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-400">{{ $key->last_used_at?->diffForHumans() ?? 'Never' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($key->revoked_at)
|
||||
<span class="text-xs text-gray-500">Revoked</span>
|
||||
@else
|
||||
<form method="POST" action="{{ route('dashboard.api-keys.revoke', $key->id) }}" class="inline" data-revoke-form>
|
||||
@csrf
|
||||
<button type="submit" class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10">Revoke</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">No API keys yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl glass-card p-6 space-y-4">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Tips</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Keep keys safe</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4 text-sm text-gray-300">
|
||||
Use separate keys for apps and automate revocation if you suspect compromise.
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4 text-sm text-gray-300">
|
||||
API keys are required to access private keyword search results.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll('[data-copy-key]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(btn.dataset.copyKey || '');
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-revoke-form]').forEach((form) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const ok = await window.dewemojiConfirm('Revoke this API key? This cannot be undone.', {
|
||||
title: 'Revoke API key',
|
||||
okText: 'Revoke',
|
||||
});
|
||||
if (!ok) return;
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
127
app/resources/views/dashboard/user/billing.blade.php
Normal file
127
app/resources/views/dashboard/user/billing.blade.php
Normal file
@@ -0,0 +1,127 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'Billing')
|
||||
@section('page_title', 'Billing')
|
||||
@section('page_subtitle', 'Subscription status and plan details.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@php
|
||||
$user = $user ?? auth()->user();
|
||||
$subscription = $subscription ?? null;
|
||||
$hasSub = $subscription !== null;
|
||||
$orders = $orders ?? collect();
|
||||
$payments = $payments ?? collect();
|
||||
@endphp
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Plan</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ $hasSub ? ucfirst($subscription->plan ?? 'Personal') : 'Free' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">{{ $hasSub ? ucfirst($subscription->status ?? 'active') : 'No subscription' }}</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Renewal</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">
|
||||
{{ $subscription?->next_renewal_at?->toDateString() ?? '—' }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Next charge date</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Status</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">
|
||||
{{ $subscription?->expires_at?->toDateString() ?? 'Active' }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Access ends</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Billing summary</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Subscription details</div>
|
||||
</div>
|
||||
<span class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-300 theme-surface">Current cycle</span>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-300">
|
||||
@if ($hasSub)
|
||||
Provider: {{ $subscription->provider ?? 'direct' }} · Started {{ $subscription->started_at?->toDateString() }}
|
||||
@else
|
||||
You are on the free plan. Upgrade to unlock private keywords and sync.
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-400">
|
||||
Downgrading to Free revokes any active API keys immediately.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="text-sm font-semibold text-gray-200">Payment method</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-white">Card</div>
|
||||
<div class="mt-1 text-xs text-gray-400">Coming soon</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="text-sm font-semibold text-gray-200">Invoices</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-white">0</div>
|
||||
<div class="mt-1 text-xs text-gray-400">Billing history</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($payments->count() > 0)
|
||||
<div class="mt-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Recent payments</div>
|
||||
<div class="mt-3 overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full text-sm text-gray-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-gray-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Provider</th>
|
||||
<th class="px-4 py-3 text-left">Plan</th>
|
||||
<th class="px-4 py-3 text-left">Amount</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10">
|
||||
@foreach ($payments as $payment)
|
||||
@php
|
||||
$status = $payment->status ?? 'pending';
|
||||
$pill = $status === 'paid'
|
||||
? ['bg' => 'bg-emerald-100 dark:bg-emerald-500/20', 'text' => 'text-emerald-800 dark:text-emerald-200']
|
||||
: ($status === 'failed'
|
||||
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
|
||||
: ['bg' => 'bg-amber-100 dark:bg-amber-500/20', 'text' => 'text-amber-800 dark:text-amber-200']);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $payment->provider ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $payment->plan_code ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $payment->currency ?? 'USD' }} {{ number_format((float) ($payment->amount ?? 0), 2) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">{{ $status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-400">{{ $payment->created_at?->toDateString() ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($payments->contains(fn ($payment) => in_array($payment->status, ['pending', 'failed'], true)))
|
||||
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
Pending or failed payments need a new checkout. Start a fresh transaction from the pricing page.
|
||||
</div>
|
||||
<a href="{{ route('pricing') }}" class="mt-3 inline-flex items-center justify-center rounded-full border border-amber-300 px-4 py-2 text-xs font-semibold text-amber-800 hover:bg-amber-100 dark:border-amber-300/40 dark:text-amber-200 dark:hover:bg-amber-400/10">
|
||||
Start new checkout
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (!$hasSub || (string) $user?->tier !== 'personal')
|
||||
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-brand-sun/30 dark:bg-brand-sun/10 dark:text-brand-sun">
|
||||
Upgrade to Personal for private keywords and synced personalization.
|
||||
</div>
|
||||
<a href="{{ route('pricing') }}" class="mt-4 inline-flex items-center justify-center rounded-full bg-brand-sun text-black font-semibold px-4 py-2 text-sm">
|
||||
Upgrade to Personal
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
251
app/resources/views/dashboard/user/keywords.blade.php
Normal file
251
app/resources/views/dashboard/user/keywords.blade.php
Normal file
@@ -0,0 +1,251 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'My Keywords')
|
||||
@section('page_title', 'My Keywords')
|
||||
@section('page_subtitle', 'Manage your private emoji keywords and language tags.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@php
|
||||
$user = $user ?? auth()->user();
|
||||
$isPersonal = $user && (string) $user->tier === 'personal';
|
||||
$freeLimit = $freeLimit ?? null;
|
||||
$limitReached = $freeLimit !== null && $items->count() >= $freeLimit;
|
||||
$emojiLookup = $emojiLookup ?? [];
|
||||
@endphp
|
||||
|
||||
@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="rounded-3xl glass-card p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Keyword library</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">{{ $isPersonal ? 'Ready to personalize' : 'Free plan keywords' }}</div>
|
||||
<p class="mt-2 text-sm text-gray-400">Add keywords to emojis to improve your personal search results.</p>
|
||||
@if (!$isPersonal && $freeLimit)
|
||||
<p class="mt-1 text-xs text-gray-500">Free plan limit: {{ $items->count() }} / {{ $freeLimit }} keywords.</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button id="add-keyword-btn" class="rounded-full bg-brand-ocean text-white font-semibold px-4 py-2 text-sm {{ $limitReached ? 'opacity-50 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>
|
||||
+ Add Keyword
|
||||
</button>
|
||||
<button id="import-btn" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5 {{ $limitReached ? 'opacity-50 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>
|
||||
Import JSON
|
||||
</button>
|
||||
<a href="{{ route('dashboard.keywords.export') }}" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5">
|
||||
Export JSON
|
||||
</a>
|
||||
<div class="relative">
|
||||
<input id="keyword-search" type="text" placeholder="Search keywords..." class="rounded-full bg-white/5 border border-white/10 px-4 py-2 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!$isPersonal && $freeLimit)
|
||||
<div class="mt-6 rounded-2xl border border-brand-sun/30 bg-brand-sun/10 p-4 text-sm text-brand-sun">
|
||||
Free plan includes up to {{ $freeLimit }} keywords total. Upgrade for unlimited keywords.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div id="import-panel" class="mt-6 hidden rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<form method="POST" action="{{ route('dashboard.keywords.import') }}" enctype="multipart/form-data" class="grid gap-3 md:grid-cols-2">
|
||||
@csrf
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">JSON file</label>
|
||||
<input type="file" name="file" accept="application/json" class="block w-full text-sm text-gray-200 file:mr-4 file:rounded-full file:border-0 file:bg-brand-ocean file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-brand-oceanSoft" {{ $limitReached ? 'disabled' : '' }}>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Or paste JSON</label>
|
||||
<textarea name="payload" rows="4" class="w-full rounded-2xl bg-black/30 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder='[{"emoji_slug":"sparkles","keyword":"magic","lang":"en"}]'></textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-2">
|
||||
<button type="button" id="import-cancel" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5">Cancel</button>
|
||||
<button type="submit" class="rounded-full bg-brand-ocean text-white font-semibold px-5 py-2 text-sm" {{ $limitReached ? 'disabled' : '' }}>Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full text-sm text-gray-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-gray-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Emoji</th>
|
||||
<th class="px-4 py-3 text-left">Keyword</th>
|
||||
<th class="px-4 py-3 text-left">Language</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keyword-table">
|
||||
@forelse ($items as $item)
|
||||
<tr class="border-t border-white/5 keyword-row" data-keyword="{{ strtolower($item->keyword) }}" data-slug="{{ strtolower($item->emoji_slug) }}">
|
||||
@php
|
||||
$lookup = $emojiLookup[$item->emoji_slug] ?? null;
|
||||
@endphp
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">{{ $lookup['emoji'] ?? '⬚' }}</span>
|
||||
<div>
|
||||
<div class="text-sm text-white">{{ $lookup['name'] ?? $item->emoji_slug }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $item->emoji_slug }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-semibold text-white">{{ $item->keyword }}</td>
|
||||
<td class="px-4 py-3 text-xs uppercase tracking-[0.15em] text-gray-400">{{ $item->lang ?? 'und' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="edit-btn rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10"
|
||||
data-id="{{ $item->id }}"
|
||||
data-emoji="{{ $item->emoji_slug }}"
|
||||
data-keyword="{{ $item->keyword }}"
|
||||
data-lang="{{ $item->lang }}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<form method="POST" action="{{ route('dashboard.keywords.delete', $item->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="keyword-modal" class="hidden fixed inset-0 z-50 items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
|
||||
<div class="relative z-10 w-full max-w-lg rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-white" id="keyword-modal-title">Add keyword</h3>
|
||||
<button id="keyword-modal-close" class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-gray-200">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="keyword-form" method="POST" action="{{ route('dashboard.keywords.store') }}" class="mt-4 grid gap-4">
|
||||
@csrf
|
||||
<input type="hidden" name="_method" id="keyword-form-method" value="POST">
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Emoji Slug</label>
|
||||
<input type="text" name="emoji_slug" id="keyword-emoji" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="sparkles" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Keyword</label>
|
||||
<input type="text" name="keyword" id="keyword-text" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="magic" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Language</label>
|
||||
<input type="text" name="lang" id="keyword-lang" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="en">
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button type="button" id="keyword-cancel" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5">Cancel</button>
|
||||
<button type="submit" class="rounded-full bg-brand-ocean text-white font-semibold px-5 py-2 text-sm">Save keyword</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const isPersonal = @json($isPersonal);
|
||||
const limitReached = @json($limitReached);
|
||||
const modal = document.getElementById('keyword-modal');
|
||||
const modalTitle = document.getElementById('keyword-modal-title');
|
||||
const openBtn = document.getElementById('add-keyword-btn');
|
||||
const closeBtn = document.getElementById('keyword-modal-close');
|
||||
const cancelBtn = document.getElementById('keyword-cancel');
|
||||
const form = document.getElementById('keyword-form');
|
||||
const methodEl = document.getElementById('keyword-form-method');
|
||||
const emojiEl = document.getElementById('keyword-emoji');
|
||||
const textEl = document.getElementById('keyword-text');
|
||||
const langEl = document.getElementById('keyword-lang');
|
||||
const searchEl = document.getElementById('keyword-search');
|
||||
const importBtn = document.getElementById('import-btn');
|
||||
const importPanel = document.getElementById('import-panel');
|
||||
const importCancel = document.getElementById('import-cancel');
|
||||
|
||||
const openModal = (mode, data = {}) => {
|
||||
if (mode === 'add' && limitReached) return;
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
modalTitle.textContent = mode === 'edit' ? 'Edit keyword' : 'Add keyword';
|
||||
form.action = mode === 'edit' ? `/dashboard/keywords/${data.id}` : '{{ route('dashboard.keywords.store') }}';
|
||||
methodEl.value = mode === 'edit' ? 'PUT' : 'POST';
|
||||
emojiEl.value = data.emoji || '';
|
||||
textEl.value = data.keyword || '';
|
||||
langEl.value = data.lang || '';
|
||||
emojiEl.focus();
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
};
|
||||
|
||||
openBtn?.addEventListener('click', () => openModal('add'));
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
cancelBtn?.addEventListener('click', closeModal);
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.edit-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
openModal('edit', {
|
||||
id: btn.dataset.id,
|
||||
emoji: btn.dataset.emoji,
|
||||
keyword: btn.dataset.keyword,
|
||||
lang: btn.dataset.lang,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener('input', () => {
|
||||
const value = searchEl.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.keyword-row').forEach((row) => {
|
||||
const match = row.dataset.keyword?.includes(value) || row.dataset.slug?.includes(value);
|
||||
row.classList.toggle('hidden', value && !match);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
importBtn?.addEventListener('click', () => {
|
||||
if (!isPersonal) return;
|
||||
importPanel.classList.remove('hidden');
|
||||
});
|
||||
importCancel?.addEventListener('click', () => {
|
||||
importPanel.classList.add('hidden');
|
||||
});
|
||||
|
||||
if (window.location.hash === '#add') {
|
||||
openModal('add');
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (window.location.hash === '#add') {
|
||||
openModal('add');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
93
app/resources/views/dashboard/user/overview.blade.php
Normal file
93
app/resources/views/dashboard/user/overview.blade.php
Normal file
@@ -0,0 +1,93 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
@section('page_title', 'Your Overview')
|
||||
@section('page_subtitle', 'Quick stats and recent keyword activity.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@php
|
||||
$user = auth()->user();
|
||||
$isPersonal = $user && (string) $user->tier === 'personal';
|
||||
$subscription = $subscription ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-4">
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Total keywords</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($totalKeywords ?? 0) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Across your emoji library</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Last 7 days</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($recentWeekCount ?? 0) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">New keywords added</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">API keys</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">{{ number_format($apiKeyCount ?? 0) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Active keys</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Plan</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-white">{{ $isPersonal ? 'Personal' : 'Free' }}</div>
|
||||
<div class="mt-2 text-sm text-gray-400">
|
||||
{{ $subscription?->status ? ucfirst($subscription->status) : 'No active subscription' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Synced devices</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">0</div>
|
||||
<div class="mt-2 text-sm text-gray-400">Coming soon</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 rounded-3xl glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Recent keywords</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Latest additions</div>
|
||||
</div>
|
||||
<a href="{{ route('dashboard.keywords') }}" class="rounded-full border border-white/10 px-4 py-2 text-xs text-gray-300 hover:bg-white/5">
|
||||
Manage keywords
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 md:grid-cols-2">
|
||||
@forelse ($recentKeywords ?? [] as $keyword)
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-500">{{ $keyword->lang ?? 'und' }}</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">{{ $keyword->keyword }}</div>
|
||||
<div class="mt-1 text-xs text-gray-400">Emoji slug: {{ $keyword->emoji_slug }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-2xl border border-dashed border-white/10 p-6 text-sm text-gray-400">
|
||||
No keywords yet. Add your first keyword from an emoji detail page or the dashboard.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl glass-card p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Next steps</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Personalize faster</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4 text-sm text-gray-300">
|
||||
Add keywords right on emoji detail pages to speed up your searches.
|
||||
</div>
|
||||
@if (!$isPersonal)
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-brand-sun/30 dark:bg-brand-sun/10 dark:text-brand-sun">
|
||||
Upgrade to Personal to unlock private keywords and sync across devices.
|
||||
</div>
|
||||
<a href="{{ route('pricing') }}" class="inline-flex items-center justify-center rounded-full bg-brand-sun text-black font-semibold px-4 py-2 text-sm">
|
||||
Upgrade to Personal
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('dashboard.keywords') }}" class="inline-flex items-center justify-center rounded-full bg-brand-ocean text-white font-semibold px-4 py-2 text-sm">
|
||||
Add keywords
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
122
app/resources/views/dashboard/user/preferences.blade.php
Normal file
122
app/resources/views/dashboard/user/preferences.blade.php
Normal file
@@ -0,0 +1,122 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'Preferences')
|
||||
@section('page_title', 'Preferences')
|
||||
@section('page_subtitle', 'Personalize your Dewemoji experience.')
|
||||
|
||||
@section('dashboard_content')
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 rounded-3xl glass-card p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Personal settings</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">Preferences</div>
|
||||
<p class="mt-2 text-sm text-gray-400">Saved locally for now. We will sync these to your account later.</p>
|
||||
|
||||
<form id="preferences-form" class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Theme</label>
|
||||
<select id="pref-theme" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<div class="mt-1 text-xs text-gray-400">Matches the global theme toggle.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Locale</label>
|
||||
<select id="pref-locale" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="id-ID">Bahasa Indonesia</option>
|
||||
</select>
|
||||
<div class="mt-1 text-xs text-gray-400">Controls language defaults.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Tone lock</label>
|
||||
<select id="pref-tone" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="off">Off</option>
|
||||
<option value="on">Always on</option>
|
||||
</select>
|
||||
<div class="mt-1 text-xs text-gray-400">Prefer a skin tone when available.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Insert mode</label>
|
||||
<select id="pref-insert" class="mt-2 w-full rounded-xl border border-white/10 px-4 py-2 text-sm text-gray-200 theme-surface">
|
||||
<option value="copy">Copy</option>
|
||||
<option value="insert">Insert</option>
|
||||
</select>
|
||||
<div class="mt-1 text-xs text-gray-400">Default action for emoji pickers.</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="pref-status" class="mt-4 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl glass-card p-6 space-y-4">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Preferences</div>
|
||||
<div class="mt-2 text-xl font-semibold text-white">What’s next</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4 text-sm text-gray-300">
|
||||
Sync preferences across devices and extensions once account settings are wired.
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4 text-sm text-gray-300">
|
||||
Add tone presets and quick language switching.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const storageKey = 'dewemoji_prefs';
|
||||
const prefTheme = document.getElementById('pref-theme');
|
||||
const prefLocale = document.getElementById('pref-locale');
|
||||
const prefTone = document.getElementById('pref-tone');
|
||||
const prefInsert = document.getElementById('pref-insert');
|
||||
const status = document.getElementById('pref-status');
|
||||
|
||||
const readPrefs = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKey) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writePrefs = (prefs) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
if (status) {
|
||||
status.textContent = 'Saved just now.';
|
||||
setTimeout(() => { status.textContent = ''; }, 1600);
|
||||
}
|
||||
};
|
||||
|
||||
const applyThemePref = (value) => {
|
||||
if (value === 'light') localStorage.setItem('dewemoji_theme', 'light');
|
||||
if (value === 'dark') localStorage.setItem('dewemoji_theme', 'dark');
|
||||
if (value === 'system') localStorage.removeItem('dewemoji_theme');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const prefs = readPrefs();
|
||||
if (prefTheme) prefTheme.value = prefs.theme || 'system';
|
||||
if (prefLocale) prefLocale.value = prefs.locale || 'en-US';
|
||||
if (prefTone) prefTone.value = prefs.tone || 'off';
|
||||
if (prefInsert) prefInsert.value = prefs.insert || 'copy';
|
||||
|
||||
[prefTheme, prefLocale, prefTone, prefInsert].forEach((el) => {
|
||||
if (!el) return;
|
||||
el.addEventListener('change', () => {
|
||||
const next = {
|
||||
theme: prefTheme?.value || 'system',
|
||||
locale: prefLocale?.value || 'en-US',
|
||||
tone: prefTone?.value || 'off',
|
||||
insert: prefInsert?.value || 'copy',
|
||||
};
|
||||
writePrefs(next);
|
||||
if (el === prefTheme) applyThemePref(next.theme);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
25
app/resources/views/dashboard/user/profile.blade.php
Normal file
25
app/resources/views/dashboard/user/profile.blade.php
Normal file
@@ -0,0 +1,25 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('title', 'Profile')
|
||||
@section('page_title', 'Profile')
|
||||
@section('page_subtitle', 'Update your account details and security settings.')
|
||||
|
||||
@section('dashboard_content')
|
||||
@if (session('status') === 'profile-updated')
|
||||
<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">
|
||||
Profile updated.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6">
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
@include('profile.partials.update-password-form')
|
||||
</div>
|
||||
<div class="rounded-3xl glass-card p-6">
|
||||
@include('profile.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user