Implement catalog CRUD overhaul, snapshot fallback activation, and billing/UX hardening

This commit is contained in:
Dwindi Ramadhana
2026-02-17 00:03:35 +07:00
parent e6aef31dd1
commit 2726b6c312
37 changed files with 2936 additions and 204 deletions

View File

@@ -16,6 +16,7 @@
$userTier = $userTier ?? $user?->tier;
$isPersonal = $userTier === 'personal';
$userKeywords = $userKeywords ?? collect();
$activeKeywordCount = (int) ($activeKeywordCount ?? $userKeywords->where('is_active', true)->count());
$htmlHex = '';
$cssCode = '';
if (!empty($emoji['codepoints'][0])) {
@@ -214,17 +215,29 @@
@if ($canManageKeywords)
@if (!is_null($keywordLimit))
<div class="mt-3 text-xs text-gray-400">
Free plan limit: {{ $userKeywords->count() }} / {{ $keywordLimit }} keywords.
Free active limit: {{ $activeKeywordCount }} / {{ $keywordLimit }} keywords.
</div>
@endif
<div id="user-keyword-list" class="mt-4 flex flex-wrap gap-2">
@forelse ($userKeywords as $keyword)
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-gray-200">
@php($isKeywordActive = (bool) ($keyword->is_active ?? true))
<span
class="user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm {{ $isKeywordActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300' }}"
data-id="{{ $keyword->id }}"
data-keyword="{{ $keyword->keyword }}"
data-lang="{{ $keyword->lang ?? 'und' }}"
data-active="{{ $isKeywordActive ? '1' : '0' }}"
>
<span>{{ $keyword->keyword }}</span>
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">{{ $keyword->lang ?? 'und' }}</span>
@unless($isKeywordActive)
<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>
@endunless
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
</span>
@empty
<span class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
<span id="user-keyword-empty" class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
@endforelse
</div>
@else
@@ -254,7 +267,7 @@
<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 theme-surface p-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Add keyword</h3>
<h3 id="user-keyword-title" class="text-lg font-semibold text-slate-900 dark:text-white">Add keyword</h3>
<button id="user-keyword-close" class="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-white/10 dark:hover:bg-white/20 flex items-center justify-center text-slate-600 dark:text-gray-200">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
@@ -277,7 +290,7 @@
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" id="user-keyword-cancel" class="rounded-full border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-white/10 dark:text-gray-200 dark:hover:bg-white/5">Cancel</button>
<button type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm">Save keyword</button>
<button id="user-keyword-submit" type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm">Save keyword</button>
</div>
</form>
</div>
@@ -337,6 +350,8 @@ addRecent(@json($symbol));
const closeBtn = document.getElementById('user-keyword-close');
const cancelBtn = document.getElementById('user-keyword-cancel');
const form = document.getElementById('user-keyword-form');
const modalTitle = document.getElementById('user-keyword-title');
const submitBtn = document.getElementById('user-keyword-submit');
const keywordInput = document.getElementById('user-keyword-input');
const langInput = document.getElementById('user-keyword-lang');
const langComboInput = document.getElementById('user-keyword-lang-combo');
@@ -344,6 +359,10 @@ addRecent(@json($symbol));
const langSourceEl = document.getElementById('user-keyword-lang-source');
const list = document.getElementById('user-keyword-list');
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const updateUrlTemplate = @json(route('dashboard.keywords.update', ['keyword' => '__ID__']));
const deleteUrlTemplate = @json(route('dashboard.keywords.delete', ['keyword' => '__ID__']));
let editingKeywordId = null;
const escHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
@@ -397,25 +416,60 @@ addRecent(@json($symbol));
return UNDETERMINED_LANGUAGE_CODE;
};
const openModal = () => {
if (limitReached) {
const setModalMode = (mode = 'add') => {
if (mode === 'edit') {
modalTitle.textContent = 'Edit keyword';
submitBtn.textContent = 'Save changes';
return;
}
modalTitle.textContent = 'Add keyword';
submitBtn.textContent = 'Save keyword';
};
const buildKeywordPill = (item) => {
const pill = document.createElement('span');
const isActive = Boolean(item?.is_active ?? true);
const lang = String(item?.lang || 'und');
pill.className = `user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm ${isActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300'}`;
pill.dataset.id = String(item?.id || '');
pill.dataset.keyword = String(item?.keyword || '');
pill.dataset.lang = lang;
pill.dataset.active = isActive ? '1' : '0';
pill.innerHTML = `
<span>${escHtml(String(item?.keyword || ''))}</span>
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">${escHtml(lang)}</span>
${isActive ? '' : '<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>'}
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
`;
return pill;
};
const openModal = (mode = 'add', item = null) => {
if (mode === 'add' && limitReached) {
showToast('Free plan keyword limit reached');
return;
}
editingKeywordId = mode === 'edit' ? item?.id || null : null;
setModalMode(mode);
modal.classList.remove('hidden');
modal.classList.add('flex');
keywordInput.value = '';
setLanguageByCode(DEFAULT_LANGUAGE_CODE);
keywordInput.value = mode === 'edit' ? (item?.keyword || '') : '';
setLanguageByCode(mode === 'edit' ? (item?.lang || UNDETERMINED_LANGUAGE_CODE) : DEFAULT_LANGUAGE_CODE, {
silentInput: mode === 'edit' && String(item?.lang || '').toLowerCase() === UNDETERMINED_LANGUAGE_CODE,
});
closeLanguageMenu();
keywordInput.focus();
};
const closeModal = () => {
editingKeywordId = null;
setModalMode('add');
modal.classList.add('hidden');
modal.classList.remove('flex');
};
openBtn?.addEventListener('click', openModal);
openBtn?.addEventListener('click', () => openModal('add'));
closeBtn?.addEventListener('click', closeModal);
cancelBtn?.addEventListener('click', closeModal);
modal?.addEventListener('click', (e) => {
@@ -444,8 +498,12 @@ addRecent(@json($symbol));
lang: langInput.value || 'und',
};
if (!payload.keyword) return;
const res = await fetch('{{ route('dashboard.keywords.store') }}', {
method: 'POST',
const url = editingKeywordId
? updateUrlTemplate.replace('__ID__', String(editingKeywordId))
: '{{ route('dashboard.keywords.store') }}';
const method = editingKeywordId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@@ -458,16 +516,71 @@ addRecent(@json($symbol));
showToast('Could not save keyword');
return;
}
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-gray-200';
badge.innerHTML = `<span>${payload.keyword}</span><span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">${payload.lang}</span>`;
const item = data?.item || payload;
const wasEditing = Boolean(editingKeywordId);
const badge = buildKeywordPill(item);
if (list) {
const empty = list.querySelector('span.text-sm');
const empty = list.querySelector('#user-keyword-empty');
if (empty) empty.remove();
list.prepend(badge);
if (wasEditing) {
const current = list.querySelector(`.user-keyword-pill[data-id="${editingKeywordId}"]`);
if (current) current.replaceWith(badge);
else list.prepend(badge);
} else {
list.prepend(badge);
}
}
closeModal();
showToast('Keyword added');
showToast(wasEditing ? 'Keyword updated' : 'Keyword added');
});
list?.addEventListener('click', async (event) => {
const editBtn = event.target.closest('.user-keyword-edit');
const deleteBtn = event.target.closest('.user-keyword-delete');
const pill = event.target.closest('.user-keyword-pill');
if (!pill) return;
const id = pill.dataset.id;
if (!id) return;
if (editBtn) {
openModal('edit', {
id,
keyword: pill.dataset.keyword || '',
lang: pill.dataset.lang || 'und',
});
return;
}
if (!deleteBtn) return;
const ok = window.dewemojiConfirm
? await window.dewemojiConfirm('Delete this keyword?', {
title: 'Delete keyword',
okText: 'Delete',
})
: true;
if (!ok) return;
const res = await fetch(deleteUrlTemplate.replace('__ID__', id), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
showToast('Could not delete keyword');
return;
}
pill.remove();
if (!list.querySelector('.user-keyword-pill')) {
const empty = document.createElement('span');
empty.id = 'user-keyword-empty';
empty.className = 'text-sm text-gray-400';
empty.textContent = 'No private keywords yet. Add one to personalize search.';
list.appendChild(empty);
}
showToast('Keyword deleted');
});
})();