Polish UI and billing flows: route fallback, searchable selectors, light-mode fixes
This commit is contained in:
@@ -94,8 +94,8 @@
|
||||
<div class="absolute w-64 h-64 bg-yellow-500/20 rounded-full blur-3xl group-hover:bg-yellow-500/30 transition-colors duration-500"></div>
|
||||
<div class="text-[140px] md:text-[180px] leading-none select-none relative z-10 animate-float drop-shadow-2xl">{{ $symbol }}</div>
|
||||
<div class="absolute bottom-6 flex gap-3 opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0">
|
||||
<button onclick="copyToClipboard('{{ $symbol }}')" class="bg-black/50 backdrop-blur text-white p-3 rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10" title="Copy">
|
||||
<i data-lucide="copy" class="w-5 h-5"></i>
|
||||
<button onclick="copyToClipboard('{{ $symbol }}')" class="bg-black/50 backdrop-blur text-white force-white p-3 rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10" title="Copy">
|
||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick="copyToClipboard('{{ $item['emoji'] }}'); event.preventDefault(); event.stopPropagation();"
|
||||
class="absolute -bottom-1 -right-1 w-5 h-5 rounded bg-black/70 border border-white/10 text-[10px] text-gray-200 hover:bg-brand-ocean/40"
|
||||
class="absolute -bottom-1 -right-1 w-5 h-5 rounded bg-black/70 border border-white/10 text-[10px] text-white force-white hover:bg-brand-ocean/40"
|
||||
title="Copy {{ $item['name'] }}"
|
||||
>⧉</button>
|
||||
</div>
|
||||
@@ -138,8 +138,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button onclick="copyToClipboard('{{ $symbol }}')" class="flex-1 bg-brand-ocean hover:bg-brand-oceanSoft text-white font-bold h-14 rounded-xl flex items-center justify-center gap-3 text-lg transition-all shadow-[0_0_20px_rgba(32,83,255,0.35)] hover:shadow-[0_0_30px_rgba(32,83,255,0.55)]">
|
||||
<i data-lucide="copy" class="w-5 h-5"></i>
|
||||
<button onclick="copyToClipboard('{{ $symbol }}')" class="flex-1 bg-brand-ocean hover:bg-brand-oceanSoft text-white force-white font-bold h-14 rounded-xl flex items-center justify-center gap-3 text-lg transition-all shadow-[0_0_20px_rgba(32,83,255,0.35)] hover:shadow-[0_0_30px_rgba(32,83,255,0.55)]">
|
||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
||||
Copy Emoji
|
||||
</button>
|
||||
</div>
|
||||
@@ -206,12 +206,17 @@
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-brand-ocean"></i>
|
||||
<h3 class="text-sm font-bold text-gray-200 uppercase tracking-wide">Your Keywords</h3>
|
||||
</div>
|
||||
@if ($isPersonal)
|
||||
<button id="user-keyword-add" class="rounded-full bg-brand-ocean text-white text-xs font-semibold px-3 py-1.5">Add keyword</button>
|
||||
@if ($canManageKeywords)
|
||||
<button id="user-keyword-add" class="rounded-full bg-brand-ocean text-white force-white text-xs font-semibold px-3 py-1.5 {{ $limitReached ? 'opacity-60 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>Add keyword</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($isPersonal)
|
||||
@if ($canManageKeywords)
|
||||
@if (!is_null($keywordLimit))
|
||||
<div class="mt-3 text-xs text-gray-400">
|
||||
Free plan limit: {{ $userKeywords->count() }} / {{ $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">
|
||||
@@ -222,17 +227,6 @@
|
||||
<span class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif ($user)
|
||||
<div class="mt-4 rounded-xl border border-brand-sun/30 bg-brand-sun/10 p-3 text-sm text-brand-sun">
|
||||
Upgrade to Personal to add private keywords for this emoji.
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2 opacity-70">
|
||||
<span class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs text-gray-300">your-tag-1</span>
|
||||
<span class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs text-gray-300">your-tag-2</span>
|
||||
<span class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs text-gray-300">your-tag-3</span>
|
||||
<span class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs text-gray-400">Unlock with Personal</span>
|
||||
</div>
|
||||
<a href="{{ route('pricing') }}" class="mt-3 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
|
||||
<div class="mt-4 rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-gray-300">
|
||||
Sign up to personalize keywords and sync across devices.
|
||||
@@ -256,7 +250,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="user-keyword-modal" class="hidden fixed inset-0 z-50 items-center justify-center">
|
||||
<div id="user-keyword-modal" class="hidden fixed inset-0 z-[90] 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 theme-surface p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -272,8 +266,14 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Language</label>
|
||||
<input type="text" id="user-keyword-lang" list="user-keyword-language-options" class="mt-2 w-full rounded-xl bg-white border border-slate-300 px-4 py-3 text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-brand-ocean dark:bg-black/40 dark:border-white/10 dark:text-gray-200 dark:placeholder-gray-500" placeholder="Search language (e.g. English)">
|
||||
@include('partials.language-datalist', ['id' => 'user-keyword-language-options'])
|
||||
<input type="hidden" id="user-keyword-lang" value="und">
|
||||
<div class="relative mt-2">
|
||||
<input type="text" id="user-keyword-lang-combo" autocomplete="off" class="w-full rounded-xl bg-white border border-slate-300 px-4 py-3 text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-brand-ocean dark:bg-black/40 dark:border-white/10 dark:text-gray-200 dark:placeholder-gray-500" placeholder="Search language (e.g. Indonesian)">
|
||||
<div id="user-keyword-lang-menu" class="hidden absolute left-0 right-0 mt-2 max-h-56 overflow-y-auto rounded-xl border border-slate-200 bg-white shadow-xl z-[100] dark:border-white/10 dark:bg-[#0b0b0f]"></div>
|
||||
</div>
|
||||
<select id="user-keyword-lang-source" class="hidden">
|
||||
@include('partials.language-datalist')
|
||||
</select>
|
||||
</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>
|
||||
@@ -329,8 +329,9 @@ document.addEventListener('keydown', (e) => {
|
||||
addRecent(@json($symbol));
|
||||
|
||||
(() => {
|
||||
const isPersonal = @json($isPersonal);
|
||||
if (!isPersonal) return;
|
||||
const canManageKeywords = @json($canManageKeywords);
|
||||
const limitReached = @json($limitReached);
|
||||
if (!canManageKeywords) return;
|
||||
const modal = document.getElementById('user-keyword-modal');
|
||||
const openBtn = document.getElementById('user-keyword-add');
|
||||
const closeBtn = document.getElementById('user-keyword-close');
|
||||
@@ -338,38 +339,74 @@ addRecent(@json($symbol));
|
||||
const form = document.getElementById('user-keyword-form');
|
||||
const keywordInput = document.getElementById('user-keyword-input');
|
||||
const langInput = document.getElementById('user-keyword-lang');
|
||||
const langDataList = document.getElementById('user-keyword-language-options');
|
||||
const langComboInput = document.getElementById('user-keyword-lang-combo');
|
||||
const langMenuEl = document.getElementById('user-keyword-lang-menu');
|
||||
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');
|
||||
|
||||
window.dewemojiPopulateLanguageOptions?.(langDataList);
|
||||
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
|
||||
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
|
||||
|
||||
const langDisplayToCode = new Map();
|
||||
langDataList?.querySelectorAll('option').forEach((option) => {
|
||||
const code = (option.dataset.code || '').trim();
|
||||
const display = (option.value || '').trim();
|
||||
if (!code || !display) return;
|
||||
langDisplayToCode.set(display.toLowerCase(), code);
|
||||
});
|
||||
const DEFAULT_LANGUAGE_CODE = 'en';
|
||||
const UNDETERMINED_LANGUAGE_CODE = 'und';
|
||||
|
||||
const normalizeLang = (value) => {
|
||||
const v = (value || '').trim();
|
||||
if (!v) return 'und';
|
||||
const byDisplay = langDisplayToCode.get(v.toLowerCase());
|
||||
if (byDisplay) return byDisplay;
|
||||
const languageItems = Array.from(langSourceEl?.options || []).map((option) => ({
|
||||
code: option.value,
|
||||
label: option.text,
|
||||
}));
|
||||
const labelByCode = new Map(languageItems.map((item) => [item.code.toLowerCase(), item.label]));
|
||||
const itemByCode = new Map(languageItems.map((item) => [item.code.toLowerCase(), item]));
|
||||
|
||||
const bracket = v.match(/\(([A-Za-z]{2,3}(?:-[A-Za-z]{2})?)\)\s*$/);
|
||||
if (bracket?.[1]) return bracket[1];
|
||||
const renderLanguageMenu = (query = '') => {
|
||||
if (!langMenuEl) return;
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = languageItems.filter((item) => item.label.toLowerCase().includes(q));
|
||||
langMenuEl.innerHTML = filtered.map((item) => {
|
||||
const selected = langInput.value === item.code;
|
||||
return `<button type="button" data-code="${item.code}" class="w-full text-left px-3 py-2 text-sm ${selected ? 'bg-brand-ocean/10 text-brand-ocean dark:text-brand-oceanSoft' : 'text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10'}">${item.label}</button>`;
|
||||
}).join('') || '<div class="px-3 py-2 text-sm text-slate-500 dark:text-gray-400">No matches</div>';
|
||||
};
|
||||
|
||||
if (/^[A-Za-z]{2,3}(?:-[A-Za-z]{2})?$/.test(v)) return v;
|
||||
return 'und';
|
||||
const openLanguageMenu = () => {
|
||||
renderLanguageMenu(langComboInput.value);
|
||||
langMenuEl.classList.remove('hidden');
|
||||
};
|
||||
|
||||
const closeLanguageMenu = () => {
|
||||
langMenuEl.classList.add('hidden');
|
||||
};
|
||||
|
||||
const setLanguageByCode = (code, { silentInput = false } = {}) => {
|
||||
const value = canonicalizeLanguageCode(code || UNDETERMINED_LANGUAGE_CODE) || UNDETERMINED_LANGUAGE_CODE;
|
||||
langInput.value = value;
|
||||
if (silentInput || value.toLowerCase() === UNDETERMINED_LANGUAGE_CODE) {
|
||||
langComboInput.value = '';
|
||||
return;
|
||||
}
|
||||
langComboInput.value = labelByCode.get(value.toLowerCase()) || value;
|
||||
};
|
||||
|
||||
const resolveLanguageCode = (raw) => {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return UNDETERMINED_LANGUAGE_CODE;
|
||||
const byCode = itemByCode.get(canonicalizeLanguageCode(value));
|
||||
if (byCode) return byCode.code;
|
||||
const byLabel = languageItems.find((item) => item.label.toLowerCase() === value.toLowerCase());
|
||||
if (byLabel) return canonicalizeLanguageCode(byLabel.code);
|
||||
return UNDETERMINED_LANGUAGE_CODE;
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
if (limitReached) {
|
||||
showToast('Free plan keyword limit reached');
|
||||
return;
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
keywordInput.value = '';
|
||||
langInput.value = '';
|
||||
setLanguageByCode(DEFAULT_LANGUAGE_CODE);
|
||||
closeLanguageMenu();
|
||||
keywordInput.focus();
|
||||
};
|
||||
|
||||
@@ -384,13 +421,27 @@ addRecent(@json($symbol));
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
langComboInput?.addEventListener('focus', openLanguageMenu);
|
||||
langComboInput?.addEventListener('input', openLanguageMenu);
|
||||
langComboInput?.addEventListener('blur', () => {
|
||||
const nextCode = resolveLanguageCode(langComboInput.value);
|
||||
setLanguageByCode(nextCode, { silentInput: nextCode.toLowerCase() === UNDETERMINED_LANGUAGE_CODE });
|
||||
setTimeout(closeLanguageMenu, 120);
|
||||
});
|
||||
langMenuEl?.addEventListener('mousedown', (event) => {
|
||||
const button = event.target.closest('button[data-code]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
setLanguageByCode(button.dataset.code);
|
||||
closeLanguageMenu();
|
||||
});
|
||||
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
emoji_slug: @json($slug),
|
||||
keyword: keywordInput.value.trim(),
|
||||
lang: normalizeLang(langInput.value),
|
||||
lang: langInput.value || 'und',
|
||||
};
|
||||
if (!payload.keyword) return;
|
||||
const res = await fetch('{{ route('dashboard.keywords.store') }}', {
|
||||
|
||||
Reference in New Issue
Block a user