From 844ad4901bd0d11925b6417990282f1d6fbbc1db Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Fri, 6 Feb 2026 14:04:41 +0700 Subject: [PATCH] feat: ui polish, docs, api hardening, and common pages --- api-test-document.md | 114 ++++++ app/.env.example | 2 + .../Controllers/Api/V1/EmojiApiController.php | 17 + .../Http/Controllers/Web/SiteController.php | 18 +- .../Billing/LicenseVerificationService.php | 83 +++- app/config/dewemoji.php | 4 +- app/public/robots.txt | 4 +- app/resources/views/site/api-docs.blade.php | 371 +++++++++++++++++- .../views/site/emoji-detail.blade.php | 44 ++- app/resources/views/site/home.blade.php | 73 +++- app/resources/views/site/layout.blade.php | 23 ++ app/resources/views/site/pricing.blade.php | 147 ++++--- app/resources/views/site/privacy.blade.php | 84 +++- app/resources/views/site/support.blade.php | 81 ++-- app/resources/views/site/terms.blade.php | 76 +++- docker-compose.yml | 7 + localhost-run-note.md | 71 ++++ rebuild-progress.md | 15 +- 18 files changed, 1106 insertions(+), 128 deletions(-) create mode 100644 api-test-document.md create mode 100644 localhost-run-note.md diff --git a/api-test-document.md b/api-test-document.md new file mode 100644 index 0000000..e553ba0 --- /dev/null +++ b/api-test-document.md @@ -0,0 +1,114 @@ +# Dewemoji API Test Document + +This is a quick, repeatable checklist for validating the API locally or on staging. + +## Base URLs + +- Local: `http://127.0.0.1:8000/v1` +- Staging: `https://dewemoji.backoffice.biz.id/v1` + +Set once in your shell for convenience: + +```bash +BASE=http://127.0.0.1:8000/v1 +# BASE=https://dewemoji.backoffice.biz.id/v1 +``` + +## Health + +```bash +curl -s "$BASE/health" | jq . +``` + +Expected: `{ "ok": true, ... }` + +## Categories + +```bash +curl -s "$BASE/categories" | jq 'keys | length' +``` + +Expected: number > 0. + +## Emoji Search + +```bash +curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length' +``` + +Expected: number > 0. + +## Emoji Detail + +```bash +curl -s "$BASE/emoji/grinning-face" | jq '.slug,.name' +``` + +Expected: `"grinning-face"` and `"grinning face"`. + +## Rate-limit (Free tier) + +```bash +curl -i "$BASE/emojis?limit=1&page=1" | grep -E "HTTP|X-RateLimit" +``` + +Expected: +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` + +Note: +- Local dev disables rate limiting by default, so headers may not appear on `http://127.0.0.1`. + +## Pro key test (if you have a key) + +```bash +KEY=YOUR_LICENSE_KEY +curl -s -H "Authorization: Bearer $KEY" "$BASE/emojis?q=love&limit=50" | jq '.limit,.plan' +``` + +Expected: +- `limit` up to 50 +- `plan` = `free` or `pro` (depending on key validation). + +## License verify / activate / deactivate + +```bash +KEY=YOUR_LICENSE_KEY + +curl -s -X POST "$BASE/license/verify" \ + -H "Authorization: Bearer $KEY" | jq . + +curl -s -X POST "$BASE/license/activate" \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/json" \ + -d '{"email":"you@example.com","product":"extension","device_id":"local-dev"}' | jq . + +curl -s -X POST "$BASE/license/deactivate" \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/json" \ + -d '{"product":"extension","device_id":"local-dev"}' | jq . +``` + +Expected: +- verify → `{ ok: true }` if key is valid. +- activate → `{ ok: true }` and `device_id` echoed. +- deactivate → `{ ok: true }`. + +## Caching (ETag) + +```bash +ETAG=$(curl -i "$BASE/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r') +curl -i -H "If-None-Match: $ETAG" "$BASE/emojis?q=love&limit=5" | head -n 1 +``` + +Expected: `HTTP/1.1 304 Not Modified` + +## Error responses + +```bash +curl -s "$BASE/emoji/this-does-not-exist" | jq . +``` + +Expected: +- `error` = `not_found` diff --git a/app/.env.example b/app/.env.example index 434f29a..aaf9998 100644 --- a/app/.env.example +++ b/app/.env.example @@ -70,6 +70,7 @@ DEWEMOJI_MAX_LIMIT=50 DEWEMOJI_FREE_MAX_LIMIT=20 DEWEMOJI_PRO_MAX_LIMIT=50 DEWEMOJI_FREE_DAILY_LIMIT=30 +DEWEMOJI_RATE_LIMIT_ENABLED=true DEWEMOJI_LICENSE_ACCEPT_ALL=true DEWEMOJI_PRO_KEYS= DEWEMOJI_LICENSE_MAX_DEVICES=3 @@ -84,6 +85,7 @@ DEWEMOJI_MAYAR_ENABLED=false DEWEMOJI_MAYAR_VERIFY_URL= DEWEMOJI_MAYAR_API_BASE= DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify +DEWEMOJI_MAYAR_PRODUCT_IDS= DEWEMOJI_MAYAR_API_KEY= DEWEMOJI_MAYAR_SECRET_KEY= DEWEMOJI_MAYAR_TIMEOUT=8 diff --git a/app/app/Http/Controllers/Api/V1/EmojiApiController.php b/app/app/Http/Controllers/Api/V1/EmojiApiController.php index 7aecfdb..40ca992 100644 --- a/app/app/Http/Controllers/Api/V1/EmojiApiController.php +++ b/app/app/Http/Controllers/Api/V1/EmojiApiController.php @@ -398,6 +398,23 @@ class EmojiApiController extends Controller private function trackDailyUsage(Request $request, string $q, string $category, string $subcategory): array { $dailyLimit = max((int) config('dewemoji.free_daily_limit', 30), 1); + $rateLimitEnabled = (bool) config('dewemoji.rate_limit_enabled', true); + + // Local development should not silently look broken because of daily metering. + if (!$rateLimitEnabled || app()->environment('local')) { + return [ + 'blocked' => false, + 'meta' => [ + 'used' => 0, + 'limit' => $dailyLimit, + 'remaining' => $dailyLimit, + 'window' => 'daily', + 'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(), + 'count_basis' => 'distinct_query', + 'metering' => 'disabled', + ], + ]; + } $key = trim((string) $request->query('key', '')); if ($key === '') { diff --git a/app/app/Http/Controllers/Web/SiteController.php b/app/app/Http/Controllers/Web/SiteController.php index ea1ca15..3a5cbd7 100644 --- a/app/app/Http/Controllers/Web/SiteController.php +++ b/app/app/Http/Controllers/Web/SiteController.php @@ -131,10 +131,14 @@ class SiteController extends Controller $items = $decoded['emojis'] ?? []; $match = null; + $byEmoji = []; foreach ($items as $item) { + $char = (string) ($item['emoji'] ?? ''); + if ($char !== '' && !isset($byEmoji[$char])) { + $byEmoji[$char] = $item; + } if (($item['slug'] ?? '') === $slug) { $match = $item; - break; } } @@ -142,8 +146,20 @@ class SiteController extends Controller abort(404); } + $relatedDetails = []; + foreach (array_slice($match['related'] ?? [], 0, 8) as $relatedEmoji) { + $relatedEmoji = (string) $relatedEmoji; + $ref = $byEmoji[$relatedEmoji] ?? null; + $relatedDetails[] = [ + 'emoji' => $relatedEmoji, + 'slug' => (string) ($ref['slug'] ?? ''), + 'name' => (string) ($ref['name'] ?? $relatedEmoji), + ]; + } + return view('site.emoji-detail', [ 'emoji' => $match, + 'relatedDetails' => $relatedDetails, 'canonicalPath' => '/emoji/'.$slug, ]); } diff --git a/app/app/Services/Billing/LicenseVerificationService.php b/app/app/Services/Billing/LicenseVerificationService.php index ced5ea1..87a2813 100644 --- a/app/app/Services/Billing/LicenseVerificationService.php +++ b/app/app/Services/Billing/LicenseVerificationService.php @@ -175,6 +175,15 @@ class LicenseVerificationService } $purchase = is_array($json['purchase'] ?? null) ? $json['purchase'] : []; + if (!$this->isTruthy($purchase['is_valid'] ?? true)) { + continue; + } + if ($this->isTruthy($purchase['refunded'] ?? false)) { + continue; + } + if ($this->isTruthy($purchase['chargebacked'] ?? false)) { + continue; + } $isRecurring = !empty($purchase['recurrence']); return [ @@ -231,6 +240,10 @@ class LicenseVerificationService if ($apiKey === '') { $apiKey = trim((string) config('dewemoji.billing.providers.mayar.secret_key', '')); } + $productIds = config('dewemoji.billing.providers.mayar.product_ids', []); + if (!is_array($productIds)) { + $productIds = []; + } if ($url === '') { return ['ok' => false, 'err' => 'mayar_missing_url']; } @@ -240,10 +253,15 @@ class LicenseVerificationService $request = Http::timeout($timeout) ->withHeaders(['Accept' => 'application/json']); if ($apiKey !== '') { - $request = $request->withToken($apiKey); + $request = $request->withToken($apiKey) + ->withHeaders(['X-API-Key' => $apiKey]); } - $response = $request->post($url, ['license_key' => $key]); + $response = $request->post($url, [ + 'license_key' => $key, + 'license' => $key, + 'key' => $key, + ]); if (!$response->successful()) { return ['ok' => false, 'err' => 'mayar_http_'.$response->status()]; @@ -255,17 +273,38 @@ class LicenseVerificationService } $data = is_array($json['data'] ?? null) ? $json['data'] : []; - $valid = (($json['success'] ?? false) === true) || (($json['valid'] ?? false) === true) || (($data['valid'] ?? false) === true); + $status = strtolower((string) ($data['status'] ?? $json['status'] ?? '')); + $valid = (($json['success'] ?? false) === true) + || (($json['valid'] ?? false) === true) + || (($data['valid'] ?? false) === true) + || in_array($status, ['active', 'paid', 'completed', 'valid'], true); if (!$valid) { return ['ok' => false, 'err' => 'mayar_invalid']; } + $productId = $this->firstString([ + $data['product_id'] ?? null, + $data['productId'] ?? null, + $data['product_code'] ?? null, + $json['product_id'] ?? null, + ]); + if (!empty($productIds) && ($productId === null || !in_array($productId, $productIds, true))) { + return ['ok' => false, 'err' => 'mayar_no_match']; + } + $planType = strtolower((string) ($data['type'] ?? 'lifetime')); + $expiresAt = $this->firstString([ + $data['expires_at'] ?? null, + $data['expired_at'] ?? null, + $data['expiry_date'] ?? null, + $data['valid_until'] ?? null, + $json['expires_at'] ?? null, + ]); return [ 'ok' => true, 'plan' => 'pro', - 'product_id' => (string) ($data['product_id'] ?? '') ?: null, - 'expires_at' => isset($data['expires_at']) ? (string) $data['expires_at'] : null, + 'product_id' => $productId, + 'expires_at' => $expiresAt, 'meta' => [ 'plan_type' => $planType, ], @@ -332,4 +371,38 @@ class LicenseVerificationService 'details' => $details, ]; } + + private function isTruthy(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int) $value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true); + } + + return false; + } + + /** + * @param array $values + */ + private function firstString(array $values): ?string + { + foreach ($values as $value) { + if (!is_string($value)) { + continue; + } + $value = trim($value); + if ($value !== '') { + return $value; + } + } + + return null; + } } diff --git a/app/config/dewemoji.php b/app/config/dewemoji.php index 30302a5..8d3ba49 100644 --- a/app/config/dewemoji.php +++ b/app/config/dewemoji.php @@ -1,7 +1,7 @@ env('DEWEMOJI_DATA_PATH', base_path('data/emojis.json')), + 'data_path' => env('DEWEMOJI_DATA_PATH') ?: base_path('data/emojis.json'), 'pagination' => [ 'default_limit' => (int) env('DEWEMOJI_DEFAULT_LIMIT', 20), @@ -11,6 +11,7 @@ return [ ], 'free_daily_limit' => (int) env('DEWEMOJI_FREE_DAILY_LIMIT', 30), + 'rate_limit_enabled' => filter_var(env('DEWEMOJI_RATE_LIMIT_ENABLED', true), FILTER_VALIDATE_BOOL), 'license' => [ 'accept_all' => filter_var(env('DEWEMOJI_LICENSE_ACCEPT_ALL', false), FILTER_VALIDATE_BOOL), @@ -36,6 +37,7 @@ return [ 'verify_url' => env('DEWEMOJI_MAYAR_VERIFY_URL', ''), 'api_base' => env('DEWEMOJI_MAYAR_API_BASE', ''), 'endpoint_verify' => env('DEWEMOJI_MAYAR_ENDPOINT_VERIFY', '/v1/license/verify'), + 'product_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_PRODUCT_IDS', ''))))), 'api_key' => env('DEWEMOJI_MAYAR_API_KEY', ''), 'secret_key' => env('DEWEMOJI_MAYAR_SECRET_KEY', ''), 'timeout' => (int) env('DEWEMOJI_MAYAR_TIMEOUT', 8), diff --git a/app/public/robots.txt b/app/public/robots.txt index eb05362..98006dc 100644 --- a/app/public/robots.txt +++ b/app/public/robots.txt @@ -1,2 +1,4 @@ User-agent: * -Disallow: +Allow: / + +Sitemap: https://dewemoji.com/sitemap.xml diff --git a/app/resources/views/site/api-docs.blade.php b/app/resources/views/site/api-docs.blade.php index 164f635..747e20b 100644 --- a/app/resources/views/site/api-docs.blade.php +++ b/app/resources/views/site/api-docs.blade.php @@ -3,6 +3,29 @@ @section('title', 'API Docs - Dewemoji') @section('meta_description', 'Dewemoji API docs for emoji search, categories, license verification, activation, and system endpoints.') +@push('head') + +@endpush + @push('jsonld') +@endpush @endsection diff --git a/app/resources/views/site/emoji-detail.blade.php b/app/resources/views/site/emoji-detail.blade.php index aad6cf2..0a682c6 100644 --- a/app/resources/views/site/emoji-detail.blade.php +++ b/app/resources/views/site/emoji-detail.blade.php @@ -19,7 +19,7 @@ $htmlHex = '&#x'.$hex.';'; $cssCode = '\\'.$hex; } - $related = array_slice($emoji['related'] ?? [], 0, 8); + $related = $relatedDetails ?? []; $keywords = array_slice($emoji['keywords_en'] ?? [], 0, 16); @endphp @@ -92,7 +92,23 @@

Related

@foreach($related as $item) - +
+ @if(!empty($item['slug'])) + {{ $item['emoji'] }} + @else +
{{ $item['emoji'] }}
+ @endif + +
@endforeach
@@ -210,8 +226,29 @@ @push('scripts') @endpush diff --git a/app/resources/views/site/home.blade.php b/app/resources/views/site/home.blade.php index 0686ec7..ad2d51c 100644 --- a/app/resources/views/site/home.blade.php +++ b/app/resources/views/site/home.blade.php @@ -10,6 +10,11 @@ backdrop-filter: blur(20px); border-bottom: 1px solid rgba(255, 255, 255, 0.05); } + #grid { + --card-min: 104px; + --emoji-size: 2rem; + grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); + } @endpush @@ -136,10 +141,15 @@

All Emojis

+ 0 / 0
-
+
@@ -202,6 +212,10 @@ const heroMain = document.getElementById('hero-main'); const heroOptional1 = document.getElementById('hero-optional-1'); const heroOptional2 = document.getElementById('hero-optional-2'); + const gridSizeEl = document.getElementById('grid-size'); + const gridSmallerEl = document.getElementById('grid-smaller'); + const gridBiggerEl = document.getElementById('grid-bigger'); + const densityStorageKey = 'dewemoji_grid_density'; if (initialQuery) qEl.value = initialQuery; @@ -262,6 +276,21 @@ return String(s || '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } + function applyGridDensity(level) { + const sizes = [ + { min: 90, emoji: '2.2rem' }, + { min: 108, emoji: '2.45rem' }, + { min: 132, emoji: '2.8rem' }, + ]; + const safe = Math.max(0, Math.min(2, Number(level) || 0)); + const conf = sizes[safe]; + grid.style.setProperty('--card-min', `${conf.min}px`); + grid.style.setProperty('--emoji-size', conf.emoji); + if (gridSizeEl) gridSizeEl.value = String(safe); + localStorage.setItem(densityStorageKey, String(safe)); + } + + function hasActiveFilters() { return qEl.value.trim() !== '' || catEl.value !== '' || subEl.value !== ''; } @@ -396,6 +425,14 @@ const res = await fetch('/v1/emojis?' + params.toString()); const data = await res.json(); + if (!res.ok) { + const msg = data.message || data.error || `API error (${res.status})`; + grid.innerHTML = `

${esc(msg)}

`; + state.total = 0; + state.items = []; + updateStats(); + return; + } state.total = data.total || 0; const incoming = data.items || []; @@ -412,13 +449,28 @@ } items.forEach((item) => { - const card = document.createElement('a'); - card.href = '/emoji/' + encodeURIComponent(item.slug); - card.className = 'aspect-square rounded-lg bg-white/5 hover:bg-white/10 flex flex-col items-center justify-center gap-1 text-center transition-transform hover:scale-105 border border-transparent hover:border-white/20'; + const card = document.createElement('div'); + card.className = 'relative aspect-square rounded-lg bg-white/5 hover:bg-white/10 transition-transform hover:scale-[1.02] border border-transparent hover:border-white/20 overflow-hidden group'; card.innerHTML = ` - ${esc(item.emoji)} - ${esc(item.name)} + + ${esc(item.emoji)} + +
+ ${esc(item.name)} + +
`; + const copyBtn = card.querySelector('.copy-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(item.emoji).then(() => { + showToast('Copied ' + item.emoji); + addRecent(item.emoji); + }); + }); + } card.addEventListener('contextmenu', (e) => { e.preventDefault(); navigator.clipboard.writeText(item.emoji).then(() => { @@ -462,6 +514,15 @@ }); }); + if (gridSizeEl && gridSmallerEl && gridBiggerEl) { + const initialDensity = localStorage.getItem(densityStorageKey) ?? '1'; + applyGridDensity(Number(initialDensity)); + + gridSizeEl.addEventListener('input', () => applyGridDensity(Number(gridSizeEl.value))); + gridSmallerEl.addEventListener('click', () => applyGridDensity(Number(gridSizeEl.value) - 1)); + gridBiggerEl.addEventListener('click', () => applyGridDensity(Number(gridSizeEl.value) + 1)); + } + (async () => { await loadCategories(); if (initialCategory && state.categories[initialCategory]) { diff --git a/app/resources/views/site/layout.blade.php b/app/resources/views/site/layout.blade.php index 6d97448..95e6041 100644 --- a/app/resources/views/site/layout.blade.php +++ b/app/resources/views/site/layout.blade.php @@ -25,6 +25,29 @@ + diff --git a/app/resources/views/site/pricing.blade.php b/app/resources/views/site/pricing.blade.php index aee2afb..e10f341 100644 --- a/app/resources/views/site/pricing.blade.php +++ b/app/resources/views/site/pricing.blade.php @@ -1,7 +1,7 @@ @extends('site.layout') @section('title', 'Pricing - Dewemoji') -@section('meta_description', 'See Dewemoji plans for Free, Pro subscription, and Lifetime. One key unlocks Extension + API with up to 3 Chrome profile activations.') +@section('meta_description', 'Choose Dewemoji pricing for Free, Pro subscription, and Lifetime access for website, extension, and API usage.') @push('jsonld')