Implement catalog CRUD overhaul, snapshot fallback activation, and billing/UX hardening
This commit is contained in:
129
app/resources/views/dashboard/admin/catalog-form.blade.php
Normal file
129
app/resources/views/dashboard/admin/catalog-form.blade.php
Normal file
@@ -0,0 +1,129 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@php
|
||||
$isEdit = ($mode ?? 'create') === 'edit';
|
||||
$formAction = $isEdit
|
||||
? route('dashboard.admin.catalog.update', data_get($selected, 'emoji_id'))
|
||||
: route('dashboard.admin.catalog.store');
|
||||
$textareas = [
|
||||
'aliases' => data_get($selected, 'aliases', []),
|
||||
'shortcodes' => data_get($selected, 'shortcodes', []),
|
||||
'alt_shortcodes' => data_get($selected, 'alt_shortcodes', []),
|
||||
'keywords_en' => data_get($selected, 'keywords_en', []),
|
||||
'keywords_id' => data_get($selected, 'keywords_id', []),
|
||||
];
|
||||
@endphp
|
||||
|
||||
@section('page_title', $isEdit ? 'Edit Emoji' : 'Create Emoji')
|
||||
@section('page_subtitle', 'Edit/add emoji data in database. Publish frozen JSON from Catalog page after batch updates.')
|
||||
|
||||
@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 (session('error'))
|
||||
<div class="mb-6 rounded-2xl border border-red-300/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-200">
|
||||
{{ session('error') }}
|
||||
</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-700 dark:text-amber-200">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<form method="POST" action="{{ $formAction }}" class="grid gap-4">
|
||||
@csrf
|
||||
@if ($isEdit)
|
||||
@method('PUT')
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Emoji
|
||||
<input name="emoji" value="{{ old('emoji', data_get($selected, 'emoji', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300 md:col-span-2">
|
||||
Slug
|
||||
<input name="slug" value="{{ old('slug', data_get($selected, 'slug', '')) }}" required class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Name
|
||||
<input name="name" value="{{ old('name', data_get($selected, 'name', '')) }}" required class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Category
|
||||
<input name="category" value="{{ old('category', data_get($selected, 'category', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Subcategory
|
||||
<input name="subcategory" value="{{ old('subcategory', data_get($selected, 'subcategory', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Unified
|
||||
<input name="unified" value="{{ old('unified', data_get($selected, 'unified', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Version
|
||||
<input name="version" value="{{ old('version', data_get($selected, 'version', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach ($textareas as $field => $values)
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
{{ strtoupper(str_replace('_', ' ', $field)) }} (comma or line-separated)
|
||||
<textarea name="{{ $field }}" rows="3" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">{{ old($field, is_array($values) ? implode(PHP_EOL, $values) : '') }}</textarea>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Description
|
||||
<textarea name="description" rows="3" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">{{ old('description', data_get($selected, 'description', '')) }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Permalink
|
||||
<input name="permalink" value="{{ old('permalink', data_get($selected, 'permalink', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Title
|
||||
<input name="title" value="{{ old('title', data_get($selected, 'title', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Meta title
|
||||
<input name="meta_title" value="{{ old('meta_title', data_get($selected, 'meta_title', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 dark:text-gray-300">
|
||||
Meta description
|
||||
<input name="meta_description" value="{{ old('meta_description', data_get($selected, 'meta_description', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-gray-300">
|
||||
<input type="hidden" name="supports_skin_tone" value="0">
|
||||
<input type="checkbox" name="supports_skin_tone" value="1" @checked(old('supports_skin_tone', data_get($selected, 'supports_skin_tone', false)))>
|
||||
<span>Supports skin tone</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('dashboard.admin.catalog') }}" class="rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-2 text-sm text-slate-700 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-white/5">Back to catalog</a>
|
||||
<button class="rounded-xl bg-slate-900 dark:bg-white/10 border border-slate-900 dark:border-slate-700 px-4 py-2 text-sm font-semibold text-white force-white hover:opacity-90">
|
||||
{{ $isEdit ? 'Update emoji' : 'Create emoji' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
175
app/resources/views/dashboard/admin/catalog.blade.php
Normal file
175
app/resources/views/dashboard/admin/catalog.blade.php
Normal file
@@ -0,0 +1,175 @@
|
||||
@extends('dashboard.app')
|
||||
|
||||
@section('page_title', 'Emoji Catalog')
|
||||
@section('page_subtitle', 'Manage emojis in database, then publish one frozen JSON snapshot when ready.')
|
||||
|
||||
@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 (session('error'))
|
||||
<div class="mb-6 rounded-2xl border border-red-300/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-200">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Catalog rows</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-slate-900 dark:text-white">{{ number_format($totalRows ?? $items->total()) }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600 dark:text-gray-400">Read from `emojis` table</div>
|
||||
@if (($filters['q'] ?? '') !== '')
|
||||
<div class="mt-1 text-xs text-slate-500 dark:text-gray-500">Filtered result: {{ number_format($items->total()) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Active snapshot</div>
|
||||
<div class="mt-3 text-2xl font-semibold text-slate-900 dark:text-white">{{ $activeVersion ?: 'None' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600 dark:text-gray-400">{{ $activeVersion ? 'Published' : 'Not published yet' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl glass-card p-5">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Active file path</div>
|
||||
<div class="mt-3 text-sm font-mono text-slate-800 dark:text-gray-200 break-all">{{ $activePath ?: config('dewemoji.data_path') }}</div>
|
||||
<div class="mt-2 text-xs text-slate-500 dark:text-gray-500">Public search/API uses this dataset.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<form method="GET" action="{{ route('dashboard.admin.catalog') }}" class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $filters['q'] ?? '' }}"
|
||||
placeholder="Search slug, name, category, subcategory"
|
||||
class="w-72 max-w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<button class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Search</button>
|
||||
<a href="{{ route('dashboard.admin.catalog') }}" class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Reset</a>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{{ route('dashboard.admin.catalog.create') }}" class="rounded-xl bg-slate-900 dark:bg-white/10 border border-slate-900 dark:border-white/10 px-4 py-2 text-sm font-semibold text-white force-white dark:text-white hover:opacity-90">
|
||||
Add Emoji
|
||||
</a>
|
||||
<form method="POST" action="{{ route('dashboard.admin.catalog.publish') }}">
|
||||
@csrf
|
||||
<button class="rounded-xl bg-brand-ocean px-4 py-2 text-sm font-semibold text-white force-white hover:opacity-90">Publish Frozen JSON</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('dashboard.admin.catalog.import_json') }}">
|
||||
@csrf
|
||||
<button class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Import Current JSON (new only)</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/80">
|
||||
<tr class="text-left text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-gray-400">
|
||||
<th class="px-3 py-3">ID</th>
|
||||
<th class="px-3 py-3">Emoji</th>
|
||||
<th class="px-3 py-3">Slug</th>
|
||||
<th class="px-3 py-3">Name</th>
|
||||
<th class="px-3 py-3">Category</th>
|
||||
<th class="px-3 py-3">Updated</th>
|
||||
<th class="px-3 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($items as $item)
|
||||
<tr class="border-t border-slate-200 dark:border-white/10 text-slate-800 dark:text-gray-200">
|
||||
<td class="px-3 py-3">#{{ $item->emoji_id }}</td>
|
||||
<td class="px-3 py-3">{{ $item->emoji ?: '⬚' }}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs">{{ $item->slug }}</td>
|
||||
<td class="px-3 py-3">{{ $item->name }}</td>
|
||||
<td class="px-3 py-3 text-slate-600 dark:text-gray-400">{{ $item->category }}</td>
|
||||
<td class="px-3 py-3 text-xs text-slate-500 dark:text-gray-400">{{ $item->updated_at ? \Illuminate\Support\Carbon::parse($item->updated_at)->format('Y-m-d H:i') : '—' }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('dashboard.admin.catalog.edit', ['emojiId' => $item->emoji_id]) }}" class="rounded-lg border border-slate-300 dark:border-white/10 px-3 py-1.5 text-xs text-slate-700 dark:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/10">Edit</a>
|
||||
<form method="POST" action="{{ route('dashboard.admin.catalog.delete', $item->emoji_id) }}" onsubmit="return confirm('Delete this emoji and related public records?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="rounded-lg border border-red-300/50 bg-red-50 dark:bg-red-500/10 px-3 py-1.5 text-xs text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/20">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-3 py-6 text-center text-slate-500 dark:text-gray-400">
|
||||
@if (($filters['q'] ?? '') !== '')
|
||||
No rows match "<span class="font-semibold text-slate-700 dark:text-gray-300">{{ $filters['q'] }}</span>".
|
||||
@else
|
||||
No catalog rows in database.
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $items->links('vendor.pagination.dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-2xl glass-card p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-600 dark:text-gray-400 font-semibold">Snapshot Versions</div>
|
||||
<div class="mt-1 text-sm text-slate-700 dark:text-gray-300">Use this for quick rollback if latest publish is broken.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/80">
|
||||
<tr class="text-left text-xs uppercase tracking-[0.16em] text-slate-600 dark:text-gray-400">
|
||||
<th class="px-3 py-3">Version</th>
|
||||
<th class="px-3 py-3">File</th>
|
||||
<th class="px-3 py-3">Updated</th>
|
||||
<th class="px-3 py-3">Status</th>
|
||||
<th class="px-3 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse (($snapshots ?? []) as $snapshot)
|
||||
<tr class="border-t border-slate-200 dark:border-white/10 text-slate-900 dark:text-gray-200">
|
||||
<td class="px-3 py-3 font-mono text-xs text-slate-800 dark:text-gray-100">{{ $snapshot['version'] }}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs text-slate-700 dark:text-gray-300">{{ $snapshot['name'] }}</td>
|
||||
<td class="px-3 py-3 text-xs text-slate-700 dark:text-gray-300">
|
||||
{{ $snapshot['modified_at'] > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($snapshot['modified_at'])->format('Y-m-d H:i') : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
@if ($snapshot['is_active'])
|
||||
<span class="rounded-full border border-emerald-400 bg-emerald-100 px-2 py-1 text-xs font-semibold text-emerald-800 dark:border-emerald-300/40 dark:bg-emerald-500/20 dark:text-emerald-200">Active</span>
|
||||
@else
|
||||
<span class="rounded-full border border-slate-300 dark:border-white/10 bg-slate-100 dark:bg-white/5 px-2 py-1 text-xs font-medium text-slate-700 dark:text-gray-300">Inactive</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
@if (!$snapshot['is_active'])
|
||||
<form method="POST" action="{{ route('dashboard.admin.catalog.snapshot.activate') }}" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="snapshot" value="{{ $snapshot['name'] }}">
|
||||
<button class="rounded-lg border border-slate-300 dark:border-white/10 bg-white dark:bg-transparent px-3 py-1.5 text-xs font-medium text-slate-800 dark:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/10">
|
||||
Activate
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-3 py-6 text-center text-slate-600 dark:text-gray-400">No snapshot files found yet. Publish once to create versioned snapshots.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -42,7 +42,7 @@
|
||||
<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="canceled" @selected(($filters['status'] ?? '') === 'canceled')>Canceled</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>
|
||||
@@ -165,7 +165,7 @@
|
||||
<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);
|
||||
$inactive = in_array($row->status, ['revoked', 'canceled', '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'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<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);
|
||||
$inactive = in_array($row->status, ['revoked', 'canceled', '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'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
['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' => 'Catalog', 'route' => 'dashboard.admin.catalog', 'icon' => 'package-search'],
|
||||
['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'],
|
||||
@@ -113,6 +114,9 @@
|
||||
<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.catalog') }}" 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="package-search" class="w-4 h-4"></i><span>Manage catalog</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>
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
$hasSub = $subscription !== null;
|
||||
$orders = $orders ?? collect();
|
||||
$payments = $payments ?? collect();
|
||||
$currentPlan = (string) ($subscription->plan ?? ($user?->tier === 'personal' ? 'personal_monthly' : 'free'));
|
||||
$hasPendingPayment = $payments->contains(fn ($payment) => (string) ($payment->status ?? '') === 'pending');
|
||||
$pendingCooldownWindow = (int) config('dewemoji.billing.pending_cooldown_seconds', 120);
|
||||
$latestPendingPayment = $payments->first(fn ($payment) => (string) ($payment->status ?? '') === 'pending');
|
||||
$pendingCooldownRemaining = 0;
|
||||
if ($latestPendingPayment?->created_at && $pendingCooldownWindow > 0) {
|
||||
$age = max(0, now()->getTimestamp() - $latestPendingPayment->created_at->getTimestamp());
|
||||
$pendingCooldownRemaining = max(0, $pendingCooldownWindow - $age);
|
||||
}
|
||||
$formatPlan = function (?string $code): string {
|
||||
$value = (string) ($code ?? '');
|
||||
return match ($value) {
|
||||
@@ -64,6 +73,14 @@
|
||||
<div class="mt-3 text-xs text-gray-400">
|
||||
Downgrading to Free revokes any active API keys immediately.
|
||||
</div>
|
||||
@if ($hasPendingPayment)
|
||||
<div class="mt-3 rounded-xl border border-emerald-300/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200">
|
||||
You have a pending checkout. Use Pay in the table below to continue the same payment.
|
||||
@if ($pendingCooldownRemaining > 0)
|
||||
New checkout unlocks in <span id="pending-cooldown-seconds" data-seconds="{{ $pendingCooldownRemaining }}" class="font-semibold">{{ $pendingCooldownRemaining }}</span>s.
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
@@ -78,6 +95,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="text-sm font-semibold text-gray-200">Change plan</div>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
Plan change policy: when your new payment is confirmed, Dewemoji cancels the previous recurring plan automatically.
|
||||
No prorated refund is applied.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if (in_array($currentPlan, ['personal_monthly', 'personal_annual'], true))
|
||||
@if ($currentPlan === 'personal_monthly')
|
||||
<a href="{{ route('pricing', ['period' => 'annual', 'currency' => 'USD']) }}"
|
||||
class="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10">
|
||||
Switch to Annual
|
||||
</a>
|
||||
@endif
|
||||
@if ($currentPlan === 'personal_annual')
|
||||
<a href="{{ route('pricing', ['period' => 'monthly', 'currency' => 'USD']) }}"
|
||||
class="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10">
|
||||
Switch to Monthly
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('pricing', ['target' => 'lifetime', 'currency' => 'USD']) }}"
|
||||
class="rounded-full bg-brand-sun text-black px-4 py-2 text-xs font-semibold hover:opacity-90">
|
||||
Upgrade to Lifetime
|
||||
</a>
|
||||
@elseif ($currentPlan === 'personal_lifetime')
|
||||
<span class="rounded-full border border-emerald-300/40 bg-emerald-50 px-4 py-2 text-xs font-semibold text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200">
|
||||
Lifetime active
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ route('pricing') }}"
|
||||
class="rounded-full bg-brand-sun text-black px-4 py-2 text-xs font-semibold hover:opacity-90">
|
||||
Choose Personal Plan
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($payments->count() > 0)
|
||||
<div class="mt-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Recent payments</div>
|
||||
@@ -90,6 +144,7 @@
|
||||
<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>
|
||||
<th class="px-4 py-3 text-left">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10">
|
||||
@@ -110,6 +165,20 @@
|
||||
<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>
|
||||
<td class="px-4 py-3">
|
||||
@if ($status === 'pending')
|
||||
<button
|
||||
type="button"
|
||||
class="resume-payment-btn inline-flex items-center justify-center rounded-full border border-brand-ocean/40 px-3 py-1 text-xs font-semibold text-brand-ocean hover:bg-brand-ocean/10"
|
||||
data-payment-id="{{ $payment->id }}"
|
||||
data-provider="{{ strtolower((string) ($payment->provider ?? '')) }}"
|
||||
>
|
||||
Pay
|
||||
</button>
|
||||
@else
|
||||
<span class="text-xs text-gray-500">—</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@@ -117,7 +186,7 @@
|
||||
</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.
|
||||
Failed payments need a new checkout. Pending payments can be continued from the table using the Pay action.
|
||||
</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
|
||||
@@ -135,4 +204,243 @@
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="billing-qris-modal" class="hidden fixed inset-0 z-[70] 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 bg-white/95 text-slate-900 dark:bg-slate-950/90 dark:text-white">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">QRIS payment</div>
|
||||
<h3 class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">Scan to pay</h3>
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl bg-white/10 border border-white/10 p-4 flex items-center justify-center">
|
||||
<div id="billing-qris-code" class="rounded-xl bg-white p-3 shadow-lg"></div>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-gray-300">
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-3">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Amount</div>
|
||||
<div id="billing-qris-amount" class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">Rp 0</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-3">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Expires</div>
|
||||
<div id="billing-qris-expiry" class="mt-1 text-sm text-gray-700 dark:text-gray-300">Complete within 30 minutes</div>
|
||||
</div>
|
||||
<div id="billing-qris-text" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<button id="billing-qris-cancel" class="rounded-full bg-rose-500 text-white font-semibold px-4 py-2 text-sm hover:bg-rose-600">
|
||||
Cancel payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const cooldownEl = document.getElementById('pending-cooldown-seconds');
|
||||
if (cooldownEl) {
|
||||
let remaining = Math.max(0, Number(cooldownEl.dataset.seconds || 0));
|
||||
const tick = () => {
|
||||
cooldownEl.textContent = String(remaining);
|
||||
if (remaining <= 0) return false;
|
||||
remaining -= 1;
|
||||
return true;
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(() => {
|
||||
if (!tick()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const resumeButtons = document.querySelectorAll('.resume-payment-btn');
|
||||
if (!resumeButtons.length) return;
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const resumeUrlTpl = @json(route('billing.payments.resume', ['payment' => '__PAYMENT_ID__']));
|
||||
const pakasirStatusUrl = @json(route('billing.pakasir.status'));
|
||||
const pakasirCancelUrl = @json(route('billing.pakasir.cancel'));
|
||||
const billingSuccessUrl = @json(route('dashboard.billing', ['status' => 'success']));
|
||||
|
||||
const modal = document.getElementById('billing-qris-modal');
|
||||
const qrTarget = document.getElementById('billing-qris-code');
|
||||
const qrText = document.getElementById('billing-qris-text');
|
||||
const qrAmount = document.getElementById('billing-qris-amount');
|
||||
const qrExpiry = document.getElementById('billing-qris-expiry');
|
||||
const cancelBtn = document.getElementById('billing-qris-cancel');
|
||||
|
||||
let modalOpen = false;
|
||||
let pollTimer = null;
|
||||
let currentOrderId = null;
|
||||
|
||||
const openModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
modalOpen = true;
|
||||
};
|
||||
const closeModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
modalOpen = false;
|
||||
currentOrderId = null;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
const formatExpiry = (value) => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return new Intl.DateTimeFormat('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(parsed);
|
||||
};
|
||||
const resumeUrlFor = (paymentId) => resumeUrlTpl.replace('__PAYMENT_ID__', String(paymentId));
|
||||
|
||||
const startPolling = () => {
|
||||
if (!currentOrderId) return;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!modalOpen || !currentOrderId) return;
|
||||
try {
|
||||
const res = await fetch(pakasirStatusUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
body: JSON.stringify({ order_id: currentOrderId }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.paid) {
|
||||
closeModal();
|
||||
window.location.href = billingSuccessUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
// keep polling silently
|
||||
}
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
cancelBtn?.addEventListener('click', async () => {
|
||||
if (!currentOrderId) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
const ok = await window.dewemojiConfirm('Cancel this QRIS payment? You can start a new checkout from pricing.', {
|
||||
title: 'Cancel payment',
|
||||
okText: 'Cancel payment',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetch(pakasirCancelUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
body: JSON.stringify({ order_id: currentOrderId }),
|
||||
});
|
||||
} catch (e) {
|
||||
// best-effort cancel
|
||||
} finally {
|
||||
closeModal();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!modalOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
resumeButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const paymentId = btn.dataset.paymentId;
|
||||
if (!paymentId) return;
|
||||
|
||||
const original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const res = await fetch(resumeUrlFor(paymentId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.ok) {
|
||||
const error = data?.error || 'resume_failed';
|
||||
if (error === 'payment_expired') {
|
||||
alert('This payment has expired. Start a new checkout from pricing.');
|
||||
} else if (error === 'payment_not_pending') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Could not continue this payment. Start a new checkout from pricing.');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.mode === 'redirect' && data.approve_url) {
|
||||
window.location.href = data.approve_url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.mode === 'qris' && data.payment_number) {
|
||||
currentOrderId = data.order_id || null;
|
||||
if (qrTarget) qrTarget.innerHTML = '';
|
||||
if (qrTarget && window.QRCode) {
|
||||
new QRCode(qrTarget, {
|
||||
text: data.payment_number,
|
||||
width: 220,
|
||||
height: 220,
|
||||
colorDark: '#0b0b0f',
|
||||
colorLight: '#ffffff',
|
||||
});
|
||||
}
|
||||
if (qrText) qrText.textContent = data.payment_number;
|
||||
if (qrAmount) qrAmount.textContent = `Rp ${Number(data.total_payment || data.amount || 0).toLocaleString('id-ID')}`;
|
||||
if (qrExpiry) {
|
||||
const formatted = formatExpiry(data.expired_at);
|
||||
qrExpiry.textContent = formatted ? `Expires ${formatted}` : 'Complete within 30 minutes';
|
||||
}
|
||||
openModal();
|
||||
startPolling();
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Could not continue this payment. Start a new checkout from pricing.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
} catch (e) {
|
||||
alert('Resume request failed. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
$user = $user ?? auth()->user();
|
||||
$isPersonal = $user && (string) $user->tier === 'personal';
|
||||
$freeLimit = $freeLimit ?? null;
|
||||
$limitReached = $freeLimit !== null && $items->count() >= $freeLimit;
|
||||
$activeCount = (int) ($activeCount ?? $items->where('is_active', true)->count());
|
||||
$limitReached = $freeLimit !== null && $activeCount >= $freeLimit;
|
||||
$emojiLookup = $emojiLookup ?? [];
|
||||
@endphp
|
||||
|
||||
@@ -31,7 +32,7 @@
|
||||
<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>
|
||||
<p class="mt-1 text-xs text-gray-500">Free active limit: {{ $activeCount }} / {{ $freeLimit }} keywords. Inactive keywords are stored but not used in search.</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -52,9 +53,12 @@
|
||||
|
||||
@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.
|
||||
Free plan allows up to {{ $freeLimit }} active keywords. You can keep extras as inactive and reactivate after upgrading.
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-gray-400">
|
||||
Search behavior: only <strong class="text-gray-200">Active</strong> private keywords are used in emoji matching. Inactive keywords stay saved in your account but are ignored by search and API results.
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -81,6 +85,7 @@
|
||||
<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-left">Status</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -101,6 +106,15 @@
|
||||
</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">
|
||||
@php
|
||||
$isActive = (bool) ($item->is_active ?? true);
|
||||
$canActivate = $isActive || $isPersonal || !$limitReached;
|
||||
@endphp
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $isActive ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200' : 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-200' }}">
|
||||
{{ $isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
@@ -109,9 +123,21 @@
|
||||
data-emoji="{{ $item->emoji_slug }}"
|
||||
data-keyword="{{ $item->keyword }}"
|
||||
data-lang="{{ $item->lang }}"
|
||||
>
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<form method="POST" action="{{ route('dashboard.keywords.toggle_active', $item->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="is_active" value="{{ $isActive ? '0' : '1' }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10 {{ (!$canActivate && !$isActive) ? 'opacity-50 cursor-not-allowed' : '' }}"
|
||||
{{ (!$canActivate && !$isActive) ? 'disabled' : '' }}
|
||||
>
|
||||
{{ $isActive ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('dashboard.keywords.delete', $item->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
@@ -123,7 +149,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
@@ -317,7 +343,7 @@
|
||||
}
|
||||
emojiSearchAbort = new AbortController();
|
||||
|
||||
const base = isPersonal ? emojiSearchUrl : publicEmojiSearchUrl;
|
||||
const base = emojiSearchUrl;
|
||||
const url = `${base}?${new URLSearchParams({ q, limit: '30', page: '1' }).toString()}`;
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user