Polish UI and billing flows: route fallback, searchable selectors, light-mode fixes

This commit is contained in:
Dwindi Ramadhana
2026-02-15 19:18:17 +07:00
parent a7a854886d
commit e6aef31dd1
8 changed files with 430 additions and 148 deletions

View File

@@ -293,20 +293,27 @@ class SiteController extends Controller
}
$user = request()->user();
$canManageKeywords = (bool) $user;
$isPersonal = $user && (string) $user->tier === 'personal';
$freeLimit = (int) config('dewemoji.pagination.free_max_limit', 20);
$keywordLimit = $isPersonal ? null : $freeLimit;
$userKeywords = [];
if ($isPersonal) {
if ($canManageKeywords) {
$userKeywords = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get();
}
$limitReached = $keywordLimit !== null && $userKeywords->count() >= $keywordLimit;
return view('site.emoji-detail', [
'emoji' => $match,
'relatedDetails' => $relatedDetails,
'canonicalPath' => '/emoji/'.$slug,
'userKeywords' => $userKeywords,
'canManageKeywords' => $canManageKeywords,
'keywordLimit' => $keywordLimit,
'limitReached' => $limitReached,
'userTier' => $user?->tier,
]);
}

View File

@@ -90,7 +90,7 @@
</div>
</aside>
<main class="flex-1 flex flex-col h-full min-w-0 relative z-10">
<main class="flex-1 flex flex-col h-full min-w-0 relative">
<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">

View File

@@ -35,7 +35,7 @@
@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' : '' }}>
<button id="add-keyword-btn" class="rounded-full bg-brand-ocean text-white force-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' : '' }}>
@@ -69,7 +69,7 @@
</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>
<button type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm" {{ $limitReached ? 'disabled' : '' }}>Import</button>
</div>
</form>
</div>
@@ -131,7 +131,7 @@
</div>
</div>
<div id="keyword-modal" class="hidden fixed inset-0 z-50 items-center justify-center">
<div id="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">
@@ -144,8 +144,12 @@
@csrf
<input type="hidden" name="_method" id="keyword-form-method" value="POST">
<div>
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Emoji Slug</label>
<input type="text" name="emoji_slug" id="keyword-emoji" 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="sparkles" required>
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Emoji</label>
<input type="hidden" name="emoji_slug" id="keyword-emoji" required>
<div class="relative mt-2">
<input type="text" id="keyword-emoji-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 emoji (e.g. sparkles)" required>
<div id="keyword-emoji-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>
</div>
<div>
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Keyword</label>
@@ -154,8 +158,13 @@
<div>
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Language</label>
<input type="hidden" name="lang" id="keyword-lang" value="und">
<input type="text" id="keyword-lang-display" list="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' => 'keyword-language-options'])
<div class="relative mt-2">
<input type="text" id="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="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="keyword-lang-source" class="hidden">
@include('partials.language-datalist')
</select>
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" id="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>
@@ -179,44 +188,206 @@
const form = document.getElementById('keyword-form');
const methodEl = document.getElementById('keyword-form-method');
const emojiEl = document.getElementById('keyword-emoji');
const emojiComboEl = document.getElementById('keyword-emoji-combo');
const emojiMenuEl = document.getElementById('keyword-emoji-menu');
const textEl = document.getElementById('keyword-text');
const langEl = document.getElementById('keyword-lang');
const langDisplayEl = document.getElementById('keyword-lang-display');
const langDataList = document.getElementById('keyword-language-options');
const langComboEl = document.getElementById('keyword-lang-combo');
const langMenuEl = document.getElementById('keyword-lang-menu');
const langSourceEl = document.getElementById('keyword-lang-source');
const searchEl = document.getElementById('keyword-search');
const importBtn = document.getElementById('import-btn');
const importPanel = document.getElementById('import-panel');
const importCancel = document.getElementById('import-cancel');
window.dewemojiPopulateLanguageOptions?.(langDataList);
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
const emojiLookup = @json($emojiLookup);
const emojiSearchUrl = @json(route('dashboard.keywords.search'));
const publicEmojiSearchUrl = @json(url('/v1/emojis'));
const langDisplayToCode = new Map();
const langCodeToDisplay = 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);
langCodeToDisplay.set(code.toLowerCase(), display);
});
const DEFAULT_LANGUAGE_CODE = 'en';
const UNDETERMINED_LANGUAGE_CODE = 'und';
const emojiItems = Object.entries(emojiLookup || {}).map(([slug, item]) => {
const name = String(item?.name || slug);
const glyph = String(item?.emoji || '');
const label = `${glyph ? `${glyph} ` : ''}${name}`;
return {
slug,
name,
glyph,
label,
search: `${slug} ${name} ${glyph}`.toLowerCase(),
};
}).sort((a, b) => a.name.localeCompare(b.name));
const emojiBySlug = new Map(emojiItems.map((item) => [item.slug.toLowerCase(), item]));
let remoteEmojiItems = [];
let remoteEmojiSearched = false;
let emojiQueryToken = 0;
let emojiSearchTimer = null;
let emojiSearchAbort = null;
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];
if (/^[A-Za-z]{2,3}(?:-[A-Za-z]{2})?$/.test(v)) return v;
return 'und';
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 = langEl.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>';
};
const displayLang = (code) => {
const c = (code || '').trim().toLowerCase();
if (!c) return '';
return langCodeToDisplay.get(c) || code;
const openLanguageMenu = () => {
renderLanguageMenu(langComboEl.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;
langEl.value = value;
if (silentInput || value.toLowerCase() === UNDETERMINED_LANGUAGE_CODE) {
langComboEl.value = '';
return;
}
langComboEl.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 renderEmojiMenu = (query = '') => {
if (!emojiMenuEl) return;
const q = query.trim().toLowerCase();
const useRemote = q.length >= 2 && remoteEmojiSearched;
const filtered = useRemote
? remoteEmojiItems.slice(0, 80)
: emojiItems.filter((item) => !q || item.search.includes(q)).slice(0, 80);
emojiMenuEl.innerHTML = filtered.map((item) => {
const selected = emojiEl.value === item.slug;
const glyph = item.glyph || '&nbsp;';
return `<button type="button" data-slug="${item.slug}" 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'}"><span class="inline-flex items-center gap-2"><span class="inline-flex w-6 shrink-0 justify-center">${glyph}</span><span class="truncate">${item.name}</span></span></button>`;
}).join('') || '<div class="px-3 py-2 text-sm text-slate-500 dark:text-gray-400">No matches</div>';
};
const mapApiItemsToEmojiItems = (items) => (items || []).map((item) => {
const slug = String(item?.slug || '').trim();
if (!slug) return null;
const existing = emojiBySlug.get(slug.toLowerCase());
const name = String(item?.name || existing?.name || slug);
const glyph = String(item?.emoji || existing?.glyph || '');
return {
slug,
name,
glyph,
label: `${glyph ? `${glyph} ` : ''}${name}`,
search: `${slug} ${name} ${glyph}`.toLowerCase(),
};
}).filter(Boolean);
const fetchEmojiMenuItems = async (query) => {
const q = String(query || '').trim();
if (q.length < 2) {
remoteEmojiItems = [];
remoteEmojiSearched = false;
renderEmojiMenu(q);
return;
}
const token = ++emojiQueryToken;
if (emojiSearchAbort) {
emojiSearchAbort.abort();
}
emojiSearchAbort = new AbortController();
const base = isPersonal ? emojiSearchUrl : publicEmojiSearchUrl;
const url = `${base}?${new URLSearchParams({ q, limit: '30', page: '1' }).toString()}`;
try {
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: emojiSearchAbort.signal,
});
const data = await res.json().catch(() => null);
if (token !== emojiQueryToken) return;
const items = Array.isArray(data?.items) ? data.items : [];
remoteEmojiItems = mapApiItemsToEmojiItems(items);
remoteEmojiSearched = true;
renderEmojiMenu(q);
} catch (error) {
if (error?.name === 'AbortError') return;
if (token !== emojiQueryToken) return;
remoteEmojiItems = [];
remoteEmojiSearched = true;
renderEmojiMenu(q);
}
};
const scheduleEmojiFetch = (query) => {
if (emojiSearchTimer) {
clearTimeout(emojiSearchTimer);
}
emojiSearchTimer = setTimeout(() => {
fetchEmojiMenuItems(query);
}, 180);
};
const openEmojiMenu = () => {
renderEmojiMenu(emojiComboEl.value);
emojiMenuEl?.classList.remove('hidden');
scheduleEmojiFetch(emojiComboEl.value);
};
const closeEmojiMenu = () => {
emojiMenuEl?.classList.add('hidden');
};
const setEmojiBySlug = (slug, { keepRaw = false } = {}) => {
const normalized = String(slug || '').trim().toLowerCase();
if (!normalized) {
emojiEl.value = '';
emojiComboEl.value = '';
return;
}
const item = emojiBySlug.get(normalized);
if (!item) {
emojiEl.value = normalized;
emojiComboEl.value = keepRaw ? String(slug || '') : normalized;
return;
}
emojiEl.value = item.slug;
emojiComboEl.value = item.label;
};
const resolveEmojiSlug = (raw) => {
const value = String(raw || '').trim().toLowerCase();
if (!value) return '';
const exact = emojiBySlug.get(value);
if (exact) return exact.slug;
const exactLabel = emojiItems.find((item) => item.label.toLowerCase() === value);
if (exactLabel) return exactLabel.slug;
const exactName = emojiItems.find((item) => item.name.toLowerCase() === value);
if (exactName) return exactName.slug;
const starts = emojiItems.filter((item) => item.slug.toLowerCase().startsWith(value) || item.name.toLowerCase().startsWith(value));
if (starts.length === 1) return starts[0].slug;
return '';
};
const openModal = (mode, data = {}) => {
@@ -226,11 +397,14 @@
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 || '';
setEmojiBySlug(data.emoji || '');
textEl.value = data.keyword || '';
langEl.value = (data.lang || 'und').trim() || 'und';
langDisplayEl.value = displayLang(data.lang || 'und');
emojiEl.focus();
const initialLanguage = mode === 'edit'
? (data.lang || UNDETERMINED_LANGUAGE_CODE)
: DEFAULT_LANGUAGE_CODE;
setLanguageByCode(initialLanguage, { silentInput: initialLanguage.toLowerCase() === UNDETERMINED_LANGUAGE_CODE });
closeLanguageMenu();
emojiComboEl.focus();
};
const closeModal = () => {
@@ -245,12 +419,37 @@
if (e.target === modal) closeModal();
});
langDisplayEl?.addEventListener('input', () => {
langEl.value = normalizeLang(langDisplayEl.value);
emojiComboEl?.addEventListener('focus', openEmojiMenu);
emojiComboEl?.addEventListener('input', () => {
openEmojiMenu();
scheduleEmojiFetch(emojiComboEl.value);
});
emojiComboEl?.addEventListener('blur', () => {
const nextSlug = resolveEmojiSlug(emojiComboEl.value);
setEmojiBySlug(nextSlug);
setTimeout(closeEmojiMenu, 120);
});
emojiMenuEl?.addEventListener('mousedown', (event) => {
const button = event.target.closest('button[data-slug]');
if (!button) return;
event.preventDefault();
setEmojiBySlug(button.dataset.slug);
closeEmojiMenu();
});
form?.addEventListener('submit', () => {
langEl.value = normalizeLang(langDisplayEl?.value || '');
langComboEl?.addEventListener('focus', openLanguageMenu);
langComboEl?.addEventListener('input', openLanguageMenu);
langComboEl?.addEventListener('blur', () => {
const nextCode = resolveLanguageCode(langComboEl.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();
});
document.querySelectorAll('.edit-btn').forEach((btn) => {
@@ -264,6 +463,18 @@
});
});
form?.addEventListener('submit', (event) => {
const slug = resolveEmojiSlug(emojiComboEl?.value || emojiEl?.value || '');
if (!slug) {
event.preventDefault();
alert('Please select a valid emoji from the dropdown.');
emojiComboEl?.focus();
openEmojiMenu();
return;
}
setEmojiBySlug(slug);
});
if (searchEl) {
searchEl.addEventListener('input', () => {
const value = searchEl.value.trim().toLowerCase();

View File

@@ -1,17 +1,11 @@
@php
$listId = $id ?? 'language-options';
// Fallbacks only. Full list is generated in JS via Intl APIs.
// Fallback seed list. Full set is generated in JS for capable browsers.
$languages = [
['code' => 'und', 'name' => 'Undetermined'],
['code' => 'en', 'name' => 'English'],
['code' => 'id', 'name' => 'Indonesian'],
['code' => 'es', 'name' => 'Spanish'],
['code' => 'fr', 'name' => 'French'],
['code' => 'de', 'name' => 'German'],
];
@endphp
<datalist id="{{ $listId }}" data-language-options="1">
@foreach ($languages as $language)
<option value="{{ $language['name'] }} ({{ $language['code'] }})" data-code="{{ $language['code'] }}"></option>
@endforeach
</datalist>
@foreach ($languages as $language)
<option value="{{ $language['code'] }}">{{ $language['name'] }} ({{ $language['code'] }})</option>
@endforeach

View File

@@ -226,7 +226,7 @@
<input id="ti-subcategory" class="bg-[#151518] border border-white/10 rounded-xl px-3 py-2 text-sm text-gray-200 theme-surface" placeholder="subcategory (face-smiling)">
</div>
<div class="mt-4 flex gap-3">
<button id="ti-run" type="button" class="px-4 py-2 rounded-lg bg-brand-ocean hover:bg-brand-oceanSoft text-white text-sm">Run /emojis</button>
<button id="ti-run" type="button" class="px-4 py-2 rounded-lg bg-brand-ocean hover:bg-brand-oceanSoft text-white force-white text-sm">Run /emojis</button>
<button id="ti-cats" type="button" class="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-sm">Run /categories</button>
</div>
</section>

View File

@@ -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') }}', {

View File

@@ -279,6 +279,14 @@
h2 { font-size: var(--fs-l); font-weight: var(--fw-semibold); line-height: 1.25; }
h3 { font-size: var(--fs-m); font-weight: var(--fw-semibold); line-height: 1.3; }
html.theme-light .text-white:not(.force-white) { color: var(--app-fg) !important; }
html.theme-light button.text-white,
html.theme-light a.text-white,
html.theme-light [role="button"].text-white,
html.theme-light button .text-white,
html.theme-light a .text-white,
html.theme-light [role="button"] .text-white {
color: #ffffff !important;
}
html.theme-light .text-gray-200 { color: #334155 !important; }
html.theme-light .text-gray-300 { color: #475569 !important; }
html.theme-light .text-gray-400 { color: var(--muted-text) !important; }
@@ -606,67 +614,74 @@
</script>
<script>
(() => {
const normalizeCode = (raw) => {
const input = String(raw || '').trim().replace('_', '-');
if (!input) return null;
const parts = input.split('-');
if (parts.length === 1) {
const code = parts[0].toLowerCase();
return /^[a-z]{2,3}$/.test(code) ? code : null;
}
const base = parts[0].toLowerCase();
const region = parts[1].toUpperCase();
const code = `${base}-${region}`;
return /^[a-z]{2,3}-[A-Z]{2}$/.test(code) ? code : null;
const alpha = 'abcdefghijklmnopqrstuvwxyz';
const deprecatedAliases = {
in: 'id',
iw: 'he',
ji: 'yi',
jw: 'jv',
};
window.dewemojiPopulateLanguageOptions = (target) => {
const list = typeof target === 'string' ? document.getElementById(target) : target;
if (!list || list.dataset.generated === '1') return;
const makeIso6391Codes = () => {
const codes = [];
for (let i = 0; i < alpha.length; i += 1) {
for (let j = 0; j < alpha.length; j += 1) {
codes.push(alpha[i] + alpha[j]);
}
}
return codes;
};
const codeToName = new Map();
list.querySelectorAll('option[data-code]').forEach((option) => {
const code = normalizeCode(option.dataset.code);
if (!code) return;
const fallbackName = String(option.value || '').replace(/\s+\([^)]+\)\s*$/, '').trim() || code;
codeToName.set(code, fallbackName);
});
const canonicalLanguageCode = (raw) => {
const code = String(raw || '').trim().toLowerCase();
if (!code) return '';
return deprecatedAliases[code] || code;
};
if (!codeToName.has('und')) codeToName.set('und', 'Undetermined');
window.dewemojiCanonicalLanguageCode = canonicalLanguageCode;
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
const display = typeof Intl.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'language' })
: null;
window.dewemojiPopulateLanguageSelect = (target) => {
const select = typeof target === 'string' ? document.getElementById(target) : target;
if (!(select instanceof HTMLSelectElement) || select.dataset.generated === '1') return;
Intl.supportedValuesOf('language').forEach((rawCode) => {
const code = normalizeCode(rawCode);
if (!code) return;
const name = display?.of(code) || display?.of(code.split('-')[0]) || code;
codeToName.set(code, name);
const existing = Array.from(select.options).map((opt) => ({
code: canonicalLanguageCode(opt.value),
label: (opt.textContent || '').trim(),
})).filter((row) => row.code !== '');
const byCode = new Map(existing.map((row) => [row.code.toLowerCase(), row]));
if (typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') {
const dn = new Intl.DisplayNames(['en'], { type: 'language' });
makeIso6391Codes().forEach((code) => {
const name = dn.of(code);
if (!name) return;
// Unknown/unsupported codes are echoed back by many engines.
if (name.toLowerCase() === code) return;
const canonical = canonicalLanguageCode(code);
if (!canonical) return;
byCode.set(canonical, { code: canonical, label: `${name} (${canonical})` });
});
}
const entries = Array.from(codeToName.entries()).sort((a, b) => {
if (a[0] === 'und') return -1;
if (b[0] === 'und') return 1;
return a[1].localeCompare(b[1]);
const rows = Array.from(byCode.values()).sort((a, b) => {
if (a.code === 'und') return -1;
if (b.code === 'und') return 1;
return a.label.localeCompare(b.label);
});
list.innerHTML = '';
entries.forEach(([code, name]) => {
const previous = (select.value || '').trim().toLowerCase();
select.innerHTML = '';
rows.forEach((row) => {
const option = document.createElement('option');
option.value = `${name} (${code})`;
option.dataset.code = code;
list.appendChild(option);
option.value = row.code;
option.textContent = row.label;
select.appendChild(option);
});
list.dataset.generated = '1';
select.value = previous || select.value;
select.dataset.generated = '1';
};
document.querySelectorAll('datalist[data-language-options="1"]').forEach((list) => {
window.dewemojiPopulateLanguageOptions(list);
});
})();
</script>
@stack('scripts')

View File

@@ -549,6 +549,10 @@
if (!buttons.length) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const isAuthed = @json(auth()->check());
const pakasirStatusUrl = @json(\Illuminate\Support\Facades\Route::has('billing.pakasir.status') ? route('billing.pakasir.status') : url('/billing/pakasir/status'));
const pakasirCancelUrl = @json(\Illuminate\Support\Facades\Route::has('billing.pakasir.cancel') ? route('billing.pakasir.cancel') : url('/billing/pakasir/cancel'));
const pakasirCreateUrl = @json(\Illuminate\Support\Facades\Route::has('billing.pakasir.create') ? route('billing.pakasir.create') : url('/billing/pakasir/create'));
const billingSuccessUrl = @json(\Illuminate\Support\Facades\Route::has('dashboard.billing') ? route('dashboard.billing', ['status' => 'success']) : url('/dashboard/billing?status=success'));
const modal = document.getElementById('qris-modal');
const qrTarget = document.getElementById('qris-code');
const qrText = document.getElementById('qris-text');
@@ -584,7 +588,7 @@
pollTimer = setInterval(async () => {
if (!modalOpen || !currentOrderId) return;
try {
const res = await fetch("{{ route('billing.pakasir.status') }}", {
const res = await fetch(pakasirStatusUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -596,7 +600,7 @@
const data = await res.json().catch(() => null);
if (res.ok && data?.paid) {
closeModal();
window.location.href = "{{ route('dashboard.billing', ['status' => 'success']) }}";
window.location.href = billingSuccessUrl;
}
} catch (e) {
// keep polling silently
@@ -611,7 +615,7 @@
});
if (!ok) return;
try {
await fetch("{{ route('billing.pakasir.cancel') }}", {
await fetch(pakasirCancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -657,7 +661,7 @@
btn.disabled = true;
btn.textContent = 'Generating QR...';
try {
const res = await fetch("{{ route('billing.pakasir.create') }}", {
const res = await fetch(pakasirCreateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',