Compare commits

8 Commits

Author SHA1 Message Date
Dwindi Ramadhana
88218c7798 Consolidate docs and finalize APK companion updates 2026-03-16 01:06:41 +07:00
Dwindi Ramadhana
95609dc0cf Remove site Twemoji script to restore native emoji rendering 2026-02-23 05:44:12 +07:00
Dwindi Ramadhana
87e947ee95 Improve skin tone rendering and add site Twemoji fallback 2026-02-23 05:33:44 +07:00
Dwindi Ramadhana
32e9282349 Add mobile favorites UX and Android deep link support 2026-02-22 23:32:48 +07:00
Dwindi Ramadhana
d5d925d534 Improve emoji grid density and infinite scroll behavior 2026-02-22 00:40:30 +07:00
Dwindi Ramadhana
638e1d5b20 Polish mobile emoji catalog and detail navigation 2026-02-22 00:16:13 +07:00
Dwindi Ramadhana
e8e2daccfe Simplify APK updates and harden R2 cache headers 2026-02-21 23:47:57 +07:00
Dwindi Ramadhana
6c1543bf84 Merge branch 'spike/nativephp-mobile' 2026-02-21 22:43:17 +07:00
58 changed files with 9165 additions and 4764 deletions

View File

@@ -1,25 +1,62 @@
# Dewemoji Rebuild Documentation (Corrected Sources) # Dewemoji Documentation
This documentation now uses the corrected legacy/source folders: This repository had many overlapping Markdown files. On **2026-03-12**, docs were consolidated into a smaller, maintainable set.
- `../dewemoji-api` — backend (and currently also contains working website/API pages) ## Current docs (authoritative)
- `../dewemoji-chrome-ext` — Chrome extension (active)
- `../dewemoji-site` — website repo target (currently scaffold/empty files)
## Documents 1. `README.md` (this index + quickstart)
2. `api-how-it-works.md` (API contract + smoke tests)
3. `deployment-live-walkthrough.md` (operations runbook: env, deploy, staging sync, DB access, billing runtime checks)
4. `production-env.md` (minimal production env template)
5. `dewemoji-direction-2026.md` (product direction + implementation priorities)
6. `admin-dashboard-plan.md` (admin/user dashboard scope)
7. `dewemoji-apk-companion-build-walkthrough.md` (APK build, release, versioning)
8. `dewemoji-extension-notes.md` (active extension backlog)
1. `legacy-system-audit.md` ## Local run quickstart
2. `legacy-api-spec.md`
3. `legacy-credentials-and-config.md`
4. `rebuild-progress.md`
5. `phase-1-foundation.md`
6. `phase-2-api.md`
7. `phase-3-website.md`
## Note ### Option A: Docker Compose
Previous docs referenced `dewemoji-frontend` and `dewemoji-chrome`; those references are now replaced with `dewemoji-chrome-ext` and `dewemoji-site` based on your correction. ```bash
docker compose up --build
```
## Rebuild app App URL: `http://127.0.0.1:8000`
- New app scaffold lives in `app/` (Laravel + NativePHP Desktop). Stop:
```bash
docker compose down
```
### Option B: Native Laravel
```bash
cd app
cp .env.example .env
composer install
npm install
php artisan key:generate
php artisan migrate
php artisan serve --host=127.0.0.1 --port=8000
```
In another terminal:
```bash
cd app
npm run dev
```
## Basic sanity checks
```bash
curl -s "http://127.0.0.1:8000/v1/health"
curl -s "http://127.0.0.1:8000/v1/emojis?limit=3"
```
## Documentation policy
- Keep docs practical and current.
- Merge instead of duplicating runbooks.
- If a doc becomes historical, summarize key points into an active doc, then remove it.

View File

@@ -1,61 +1,109 @@
# Admin Dashboard Plan (Power Control) # Dashboard Plan (Admin + User)
This is the internal control panel used to keep Dewemoji **safe, clean, and reliable**. This file is the single dashboard planning and operations reference.
## Purpose ## 1) Dashboard objectives
- Moderate public keywords and votes. 1. Operate Dewemoji safely (subscriptions, webhooks, settings).
- Manage licenses and activations. 2. Manage Personal plan lifecycle and pricing.
- Monitor system health and data pipelines. 3. Give Personal users fast keyword/API key management.
## Phase 1 (MVP — musthave) ## 2) Current admin routes (implemented)
### 1) Public keyword moderation - `GET /dashboard/admin/analytics`
- View **public_pending** keyword queue. - `GET /dashboard/admin/users`
- Approve / reject / block keyword. - `POST /dashboard/admin/users/tier`
- See emoji, language, proposer, vote counts. - `GET /dashboard/admin/subscriptions`
- `POST /dashboard/admin/subscriptions/grant`
- `POST /dashboard/admin/subscriptions/revoke`
- `GET /dashboard/admin/pricing`
- `POST /dashboard/admin/pricing/update`
- `POST /dashboard/admin/pricing/reset`
- `GET /dashboard/admin/webhooks`
- `POST /dashboard/admin/webhooks/{id}/replay`
- `GET /dashboard/admin/settings`
- `POST /dashboard/admin/settings/update`
### 2) Abuse controls ## 3) Admin module scope
- Blocklist terms.
- Quick “hide” keyword from public search.
- Softban repeated abusive accounts.
### 3) License management ### Analytics
- Lookup by license key.
- See activations (device_id, product).
- Revoke activation or whole license.
### 4) System health - user/subscription/payment/webhook totals
- Last JSON rebuild time. - recent webhook and billing activity
- Dataset counts (emojis, keywords).
- API usage summary (daily).
### 5) Price control (Personal plan) ### Users
- Set IDR pricing for Monthly / Annual / Lifetime.
- Optional USD display override (approx only).
- Toggle payment rails (PayPal / QRIS).
- Effective date + change log (who changed, when).
## Phase 2 (Nicetohave) - filter by tier/role/search
- controlled tier update operations
- AI moderation log viewer. ### Subscriptions and payments
- Turnstile failure analytics.
- Contributor leaderboard.
- Email queue status.
- Scheduled job history.
- Pricing experiment history.
## Suggested navigation - grant/revoke workflows
- provider/status visibility (`paypal`, `qris/pakasir`, `admin`)
- pending/paid/failed/expired status clarity
- **Dashboard** (health, quick stats) ### Webhooks
- **Keywords** (pending + public)
- **Licenses**
- **Users**
- **System** (jobs, JSON rebuild, logs)
## Access control - recent events list
- replay support
- idempotency-safe processing expectations
- Admin login uses **magiclink/OTP session** + **role=admin** check. ### Pricing
- `X-Admin-Token` is **dev/internal only** (disable in prod).
- No IP allowlist required (dynamic ISP friendly). - edit plan values and provider toggles
- Log all actions (who approved / rejected / revoked). - preserve change log snapshots for auditability
### Settings
- maintenance flag
- public access guard values (`public_enforce`, origins, extension IDs, hourly limit)
## 4) User dashboard scope
### User states
1. visitor: no dashboard
2. free logged-in: dashboard access with locked personalization areas
3. personal: full access
### User modules
- Overview (summary metrics)
- My Keywords (CRUD, filter, import/export)
- API Keys (create/revoke)
- Billing (current plan + payment history + resume pending)
- Preferences (theme/tone; optional expansion)
### UX priority
- quick-add keywords on emoji detail pages (primary)
- dashboard bulk management (secondary)
## 5) Billing integration expectations
Target data model coverage:
- `orders`
- `payments`
- `subscriptions`
- `webhook_events`
Required runtime behaviors:
1. webhook-confirmed status transitions
2. pending checkout cooldown enforcement
3. resume pending checkout from billing page
4. safe downgrade when no active subscription remains
## 6) Access and security
- Admin access is role-based session auth (`users.role = admin`).
- `X-Admin-Token` should remain internal/dev usage only.
- Log sensitive actions (tier changes, pricing updates, manual grants/revokes, webhook replays).
## 7) Implementation priorities
1. strengthen payments/subscriptions observability
2. finalize user dashboard CRUD ergonomics
3. enforce non-destructive confirmations for sensitive admin actions
4. add pagination/filter/sorting consistency across large admin lists

View File

@@ -1,355 +1,256 @@
# Dewemoji API — How It Works # Dewemoji API — Reference and Smoke Test
This document explains the current API surface in the rebuild app, including filters, headers, and request/response shapes. This is the single API documentation source for the current Laravel rebuild.
## Base URLs ## Base URLs
- Local: `http://127.0.0.1:8000/v1` - Local: `http://127.0.0.1:8000/v1`
- Staging: `https://dewemoji.backoffice.biz.id/v1` - Staging: `https://dewemoji.backoffice.biz.id/v1`
## Auth & headers Convenience:
### License key (optional for free, required for Pro) ```bash
You can send a license key in either header: BASE=http://127.0.0.1:8000/v1
# BASE=https://dewemoji.backoffice.biz.id/v1
```
- `Authorization: Bearer YOUR_LICENSE_KEY` (recommended) ## Auth and request headers
- `X-License-Key: YOUR_LICENSE_KEY` (also supported)
### Optional headers License-style auth (legacy-compatible):
- `X-Account-Id`: Optional usage association.
- `X-Dewemoji-Frontend`: Optional frontend identifier (string). - `Authorization: Bearer YOUR_LICENSE_KEY` (preferred)
- `X-License-Key: YOUR_LICENSE_KEY` (supported)
Optional headers:
- `X-Account-Id`
- `X-Dewemoji-Frontend`
Common response headers:
### Response headers youll see
- `X-Dewemoji-Tier`: `free` or `pro` - `X-Dewemoji-Tier`: `free` or `pro`
- `X-Dewemoji-Plan`: `free` or `pro` - `X-Dewemoji-Plan`: `free` or `pro`
- Ratelimit headers on free tier (page=1 only): - `ETag`
- `Cache-Control`
- Rate-limit headers on free tier page 1 requests:
- `X-RateLimit-Limit` - `X-RateLimit-Limit`
- `X-RateLimit-Remaining` - `X-RateLimit-Remaining`
- `X-RateLimit-Reset` - `X-RateLimit-Reset`
- Caching:
- `ETag`
- `Cache-Control`
## Endpoints ## Endpoint map
### GET `/emojis` Public/search:
Search emojis with filters.
- `GET /emojis`
- `GET /emoji/{slug}` or `GET /emoji?slug=...`
- `GET /categories`
- `GET /health`
License and access lifecycle:
- `POST /license/verify`
- `POST /license/activate`
- `POST /license/deactivate`
Extension verification:
- `POST /extension/verify`
- `GET /extension/search`
Metrics/internal:
- `GET /metrics-lite`
- `GET /metrics`
Admin token endpoints (`X-Admin-Token` required):
- `GET /admin/settings`
- `POST /admin/settings`
- `GET /admin/subscriptions`
- `POST /admin/subscription/grant`
- `POST /admin/subscription/revoke`
- `GET /admin/webhooks`
- `GET /admin/webhooks/{id}`
- `POST /admin/webhooks/{id}/replay`
- `GET /admin/analytics`
## Endpoint details
### `GET /emojis`
Query params: Query params:
- `q` (string): search query (also accepts `query`)
- `category` (string): category name (e.g. `Smileys & Emotion`)
- `subcategory` (string): subcategory name (will be slugified internally)
- `page` (int, default 1)
- `limit` (int):
- Free tier: max 20
- Pro tier: max 50
Behavior: - `q` or `query`
- If `q` is empty, it returns all emojis (subject to pagination). - `category`
- If `q` has multiple terms, all terms must match. - `subcategory`
- `category` must match the exact category label in the dataset. - `page` (default `1`)
- `subcategory` is matched by slugifying both the request and dataset. - `limit` (free max `20`, pro max `50`)
- `plan` field in response will be `free` or `pro`.
Example: Example:
```bash ```bash
curl -s "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | jq . curl -s "$BASE/emojis?q=love&limit=5" | jq .
``` ```
Response (shape): ### `GET /emoji/{slug}` or `GET /emoji?slug=...`
```json
{
"items": [
{
"emoji": "😍",
"name": "smiling face with heart-eyes",
"slug": "smiling-face-with-heart-eyes",
"category": "Smileys & Emotion",
"subcategory": "face-affection",
"supports_skin_tone": false,
"summary": "...",
"unified": "U+1F60D",
"codepoints": ["1F60D"],
"shortcodes": [":smiling-face-with-heart-eyes:", "..."],
"aliases": [],
"keywords_en": ["love", "heart", "..."],
"keywords_id": ["cinta", "..."],
"related": ["🥰", "🤩", "😘"],
"intent_tags": ["love", "affection"]
}
],
"total": 2131,
"page": 1,
"limit": 20,
"plan": "free"
}
```
### GET `/emoji/{slug}` or `/emoji?slug=...`
Fetch detail for a specific emoji.
Example: Example:
```bash ```bash
curl -s "http://127.0.0.1:8000/v1/emoji/grinning-face" | jq . curl -s "$BASE/emoji/grinning-face" | jq .
``` ```
Errors: Errors:
- `400` if slug is missing (`error: missing_slug`)
- `404` if slug is not found (`error: not_found`)
### GET `/categories` - `400` `missing_slug`
Returns a map of category → list of subcategories. - `404` `not_found`
### `GET /categories`
Returns `category -> subcategories[]` mapping.
Example:
```bash ```bash
curl -s "http://127.0.0.1:8000/v1/categories" | jq . curl -s "$BASE/categories" | jq .
``` ```
Response: ### `POST /license/verify`
```json
{
"Smileys & Emotion": ["face-smiling", "face-affection", "..."],
"People & Body": ["hand-fingers-open", "..."]
}
```
### POST `/license/verify`
Validate a license key.
Headers:
- `Authorization: Bearer YOUR_LICENSE_KEY` (or `X-License-Key`)
Example:
```bash ```bash
curl -s -X POST "http://127.0.0.1:8000/v1/license/verify" \ KEY=YOUR_LICENSE_KEY
-H "Authorization: Bearer YOUR_LICENSE_KEY" | jq . curl -s -X POST "$BASE/license/verify" \
-H "Authorization: Bearer $KEY" | jq .
``` ```
Success response: Success shape (example):
```json ```json
{ {
"ok": true, "ok": true,
"tier": "pro", "tier": "pro",
"source": "gumroad|mayar|sandbox|...", "source": "gumroad|mayar|sandbox|...",
"plan": "pro", "plan": "pro",
"product_id": "", "product_id": "...",
"expires_at": null, "expires_at": null,
"error": null "error": null
} }
``` ```
Errors: ### `POST /license/activate`
- `400` `missing_key`
- `401` `invalid_license`
### POST `/license/activate` ```bash
Activate a device or site session. KEY=YOUR_LICENSE_KEY
curl -s -X POST "$BASE/license/activate" \
Body (JSON): -H "Authorization: Bearer $KEY" \
```json -H "Content-Type: application/json" \
{ -d '{"email":"you@example.com","product":"extension","device_id":"local-dev"}' | jq .
"email": "you@example.com",
"product": "extension|site",
"device_id": "device-123"
}
``` ```
Notes: Notes:
- `product=site` does not require `device_id`.
- Other products require `device_id`.
### POST `/license/deactivate` - `product=site` can omit `device_id`.
Deactivate a device.
Body (JSON): ### `POST /license/deactivate`
```json
{
"product": "extension|site",
"device_id": "device-123"
}
```
### GET `/health`
Basic health check.
Response:
```json
{ "ok": true, "time": "...", "app": "Dewemoji" }
```
### GET `/metrics-lite`
Lightweight metrics (if enabled).
### GET `/metrics`
Full metrics (requires token or allowed IP).
## Extension verification (public, no login)
These endpoints allow **verified extension installs** to access public search without login.
Fallback (temporary):
- If Verified Access token is not available, the server can accept `X-Extension-Id`
as a **soft allowlist** signal. This is **spoofable** and should not be treated as
a strong security boundary.
### POST `/extension/verify`
Verifies `X-Extension-Token` by calling Google Instance ID API.
Headers:
- `X-Extension-Token: <token>`
Response:
```json
{ "ok": true, "verified": true }
```
### GET `/extension/search`
Public search for verified extension installs only.
Headers:
- `X-Extension-Token: <token>`
Example:
```bash ```bash
curl -s "http://127.0.0.1:8000/v1/extension/search?q=snail" \ KEY=YOUR_LICENSE_KEY
-H "X-Extension-Token: $EXT_TOKEN" | 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 .
``` ```
Errors: ### `GET /health`
- `403 extension_unverified`
## Caching
The API uses `ETag` and returns `304 Not Modified` if the client sends:
```
If-None-Match: "etag-value"
```
Example:
```bash ```bash
ETAG=$(curl -i "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r') curl -s "$BASE/health" | jq .
curl -i -H "If-None-Match: $ETAG" "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | head -n 1
``` ```
## Public access guard (whitelist + soft throttle) ## Public access guard
Public endpoints (`/v1/emojis`, `/v1/categories`, `/v1/emoji`) are protected by a **whitelist + hourly throttle**. Public endpoints use whitelist + throttle behavior:
Behavior: - If request is whitelisted: pass.
- If the request is **whitelisted**, it passes without throttling. - If not whitelisted and `DEWEMOJI_PUBLIC_ENFORCE=true`: `403 public_access_denied`.
- If not whitelisted: - Otherwise: soft throttle with `DEWEMOJI_PUBLIC_HOURLY_LIMIT` and `429 public_rate_limited`.
- If `DEWEMOJI_PUBLIC_ENFORCE=true``403 public_access_denied`
- Else → **soft throttle** with `DEWEMOJI_PUBLIC_HOURLY_LIMIT` (HTTP `429 public_rate_limited`)
Whitelist rules: Key env:
- `Origin` is in `DEWEMOJI_PUBLIC_ORIGINS`
- Or `X-Dewemoji-Frontend` / `User-Agent` contains a configured extension ID
Key env vars: ```env
```
DEWEMOJI_PUBLIC_ENFORCE=true|false DEWEMOJI_PUBLIC_ENFORCE=true|false
DEWEMOJI_PUBLIC_ORIGINS=https://dewemoji.com,https://www.dewemoji.com DEWEMOJI_PUBLIC_ORIGINS=https://dewemoji.com,https://www.dewemoji.com
DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000 DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000
DEWEMOJI_EXTENSION_IDS=chrome-extension://... DEWEMOJI_EXTENSION_IDS=chrome-extension://...
``` ```
Rate limit response example: Security note:
```json
{
"ok": false,
"error": "public_rate_limited",
"usage": {
"used": 3,
"limit": 3,
"remaining": 0,
"window": "hourly",
"window_ends_at": "2026-02-08T14:00:00+00:00",
"window_ends_at_unix": 1770559200
}
}
```
## Admin endpoints (token required) - `Origin`/`Referer` are not strong security controls. Use edge/WAF/API keys for stronger protection.
All admin endpoints require: ## Caching behavior
```
X-Admin-Token: <DEWEMOJI_ADMIN_TOKEN>
```
### Settings (feature flags / public access) ETag is supported. `If-None-Match` can return `304 Not Modified`.
- `GET /v1/admin/settings`
- `POST /v1/admin/settings`
Example:
```bash ```bash
curl -s -X POST "http://127.0.0.1:8000/v1/admin/settings" \ ETAG=$(curl -i "$BASE/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r')
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \ curl -i -H "If-None-Match: $ETAG" "$BASE/emojis?q=love&limit=5" | head -n 1
-H "Content-Type: application/json" \
-d '{
"settings": {
"maintenance_enabled": false,
"public_enforce": true,
"public_hourly_limit": 5000,
"public_origins": ["https://dewemoji.com","https://www.dewemoji.com"],
"public_extension_ids": ["chrome-extension://yourid"]
}
}' | jq .
``` ```
### Subscriptions (admin grant/revoke) ## Smoke test checklist
- `GET /v1/admin/subscriptions`
- `POST /v1/admin/subscription/grant` ### Health
- `POST /v1/admin/subscription/revoke`
Grant example:
```bash ```bash
curl -s -X POST "http://127.0.0.1:8000/v1/admin/subscription/grant" \ curl -s "$BASE/health" | jq .
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","plan":"personal","status":"active","provider":"admin"}' | jq .
``` ```
### Webhooks (PayPal) Expected: `{ "ok": true, ... }`
- `POST /v1/paypal/webhook` (logs + processes)
- `GET /v1/admin/webhooks`
- `GET /v1/admin/webhooks/{id}`
- `POST /v1/admin/webhooks/{id}/replay`
Deduping: ### Categories
- PayPal `id` is stored as `event_id`. Duplicate events return `{ "ok": true, "duplicate": true }`.
Note: ```bash
- PayPal **signature verification is not implemented yet** (TODO before production). curl -s "$BASE/categories" | jq 'keys | length'
### Analytics
- `GET /v1/admin/analytics` (counts for users/keywords/subscriptions/webhooks)
## Security note: Origin/Referer are spoofable
The `Origin` / `Referer` headers are **not** a strong security boundary. They are used to reduce casual abuse only.
Options to harden public access:
- **Require API keys** for unlimited access (personal users).
- **Rate limit at the edge** (Cloudflare / Nginx).
- **Signed requests** for extension (HMAC or shortlived tokens).
- **Shared secret header** for internal services.
- **WAF rules** for `/v1/*` endpoints.
## Error payloads (common)
```json
{ "ok": false, "error": "not_found" }
``` ```
Other possible errors: Expected: count > 0.
- `missing_slug`
- `missing_key`
- `invalid_license`
- `daily_limit_reached`
- `data_load_failed`
## Notes ### Search
- Current dataset contains **EN + ID** keywords. ```bash
- API reads from `app/data/emojis.json` (cache-first strategy). curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length'
- Free tier limit is enforced on **page=1** requests. ```
Expected: count > 0.
### Detail
```bash
curl -s "$BASE/emoji/grinning-face" | jq '.slug,.name'
```
### Free-tier rate-limit headers
```bash
curl -i "$BASE/emojis?limit=1&page=1" | grep -E "HTTP|X-RateLimit"
```
### Verify / activate / deactivate
Use the commands in endpoint details above.
### Error response check
```bash
curl -s "$BASE/emoji/this-does-not-exist" | jq .
```
Expected `error: not_found`.
## Legacy compatibility notes
Kept for extension/backward compatibility:
- accepts both `q` and `query`
- supports `Authorization` and `X-License-Key`
- includes `X-Dewemoji-Tier`
Legacy contract references (`/api/*` in old stack) were consolidated here. No separate legacy API spec file is needed now.

View File

@@ -1,114 +0,0 @@
# 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`

View File

@@ -1,106 +0,0 @@
# APK Direct Release Guide (Local Build + Cloudflare R2)
This is the Dewemoji direct APK release flow.
## 1) One-time setup
### Required tools (local machine)
```bash
brew install awscli
brew install --cask android-platform-tools
```
### Required environment variables
```bash
export R2_ACCOUNT_ID="..."
export R2_ACCESS_KEY_ID="..."
export R2_SECRET_ACCESS_KEY="..."
export R2_BUCKET="dewemoji-downloads"
export R2_PUBLIC_BASE_URL="https://downloads.dewemoji.com"
```
Optional:
```bash
export DEWEMOJI_APK_URL="https://dewemoji.com/downloads/dewemoji-latest.apk"
```
### Optional signing environment (recommended)
```bash
export ANDROID_KEYSTORE_PATH="/absolute/path/release.jks"
export ANDROID_KEYSTORE_PASSWORD="..."
export ANDROID_KEY_ALIAS="..."
export ANDROID_KEY_PASSWORD="..."
```
---
## 2) Canonical URLs used by app updater
- `https://dewemoji.com/downloads/version.json`
- `https://dewemoji.com/downloads/dewemoji-latest.apk`
These endpoints redirect to R2 objects.
---
## 3) Release steps
Run from repo root.
### A. Build APK
```bash
./scripts/apk/build-release.sh
```
Output APK:
- `dewemoji-capacitor/dist/apk/dewemoji-v{versionName}-{versionCode}.apk`
### B. Publish APK + metadata to R2
```bash
./scripts/apk/publish-r2.sh \
--apk dewemoji-capacitor/dist/apk/dewemoji-v1.1.2-112.apk \
--version-name 1.1.2 \
--version-code 112 \
--min-supported-version-code 100 \
--notes "Bug fixes and update UX improvements" \
--force false
```
### C. Verify published release
```bash
./scripts/apk/verify-release.sh --base-url https://dewemoji.com/downloads
```
---
## 4) Versioning rules
1. Site-only deploy: do not bump APK version and do not publish new `version.json`.
2. Runtime/app-shell change: bump `versionCode` + `versionName`, then publish.
3. `versionCode` must always increase.
4. App update prompt appears only when remote `versionCode` is higher.
---
## 5) Rollback
1. Keep all versioned APK objects immutable (never overwrite).
2. Re-upload previous good APK to `apk/dewemoji-latest.apk`.
3. Re-publish `apk/version.json` with matching checksum/version fields.
4. Re-run verify script.
---
## 6) Notes
- Direct APK update is user-confirmed install (Android policy), not silent.
- Never embed R2 credentials in app.
- Keep app update payload over HTTPS only.

View File

@@ -112,3 +112,13 @@ DEWEMOJI_EXTENSION_VERIFY_ENABLED=true
DEWEMOJI_GOOGLE_PROJECT_ID= DEWEMOJI_GOOGLE_PROJECT_ID=
DEWEMOJI_GOOGLE_SERVER_KEY= DEWEMOJI_GOOGLE_SERVER_KEY=
DEWEMOJI_EXTENSION_IDS= DEWEMOJI_EXTENSION_IDS=
DEWEMOJI_APK_RELEASE_ENABLED=false
DEWEMOJI_APK_PUBLIC_BASE_URL=https://dewemoji.com/downloads
DEWEMOJI_R2_PUBLIC_BASE_URL=
DEWEMOJI_R2_APK_LATEST_KEY=apk/dewemoji-latest.apk
DEWEMOJI_R2_APK_VERSION_KEY=apk/version.json
DEWEMOJI_APK_APP_ID=com.dewemoji.app
DEWEMOJI_APK_CHANNEL=stable
DEWEMOJI_APK_MIN_SUPPORTED_VERSION_CODE=1
DEWEMOJI_APK_ASSETLINKS_SHA256=

View File

@@ -1,59 +0,0 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -110,9 +110,9 @@ class EmojiApiController extends Controller
$page = max((int) $request->query('page', 1), 1); $page = max((int) $request->query('page', 1), 1);
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1); $defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
$maxLimit = $tier === self::TIER_PRO // Search result pagination is now feature-parity for all users.
? max((int) config('dewemoji.pagination.pro_max_limit', 50), 1) // Keyword/glossary limits are enforced elsewhere (user_keywords quota logic).
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1); $maxLimit = max((int) config('dewemoji.pagination.max_limit', 50), 1);
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit); $limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
$filtered = $this->filterItems($items, $q, $category, $subSlug); $filtered = $this->filterItems($items, $q, $category, $subSlug);

View File

@@ -301,6 +301,43 @@ class SiteController extends Controller
]); ]);
} }
public function assetLinks(): JsonResponse
{
$appId = trim((string) config('dewemoji.apk_release.app_id', ''));
$rawFingerprints = (array) config('dewemoji.apk_release.assetlinks.fingerprints', []);
$fingerprints = [];
foreach ($rawFingerprints as $fingerprint) {
$normalized = $this->normalizeApkCertFingerprint((string) $fingerprint);
if ($normalized !== '') {
$fingerprints[] = $normalized;
}
}
$fingerprints = array_values(array_unique($fingerprints));
if ($appId === '' || $fingerprints === []) {
return response()->json([], 200, [
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
return response()->json([
[
'relation' => [
'delegate_permission/common.handle_all_urls',
],
'target' => [
'namespace' => 'android_app',
'package_name' => $appId,
'sha256_cert_fingerprints' => $fingerprints,
],
],
], 200, [
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
public function privacy(): View public function privacy(): View
{ {
return view('site.privacy'); return view('site.privacy');
@@ -514,6 +551,24 @@ class SiteController extends Controller
return rtrim($base, '/').'/'.ltrim($objectKey, '/'); return rtrim($base, '/').'/'.ltrim($objectKey, '/');
} }
private function normalizeApkCertFingerprint(string $value): string
{
$clean = strtoupper(trim($value));
if ($clean === '') {
return '';
}
if (preg_match('/^[0-9A-F]{64}$/', $clean) === 1) {
return implode(':', str_split($clean, 2));
}
if (preg_match('/^[0-9A-F]{2}(?::[0-9A-F]{2}){31}$/', $clean) === 1) {
return $clean;
}
return '';
}
/** /**
* @param array<string,mixed> $emoji * @param array<string,mixed> $emoji
*/ */

View File

@@ -135,5 +135,8 @@ return [
'latest_apk' => (string) env('DEWEMOJI_R2_APK_LATEST_KEY', 'apk/dewemoji-latest.apk'), 'latest_apk' => (string) env('DEWEMOJI_R2_APK_LATEST_KEY', 'apk/dewemoji-latest.apk'),
'version_json' => (string) env('DEWEMOJI_R2_APK_VERSION_KEY', 'apk/version.json'), 'version_json' => (string) env('DEWEMOJI_R2_APK_VERSION_KEY', 'apk/version.json'),
], ],
'assetlinks' => [
'fingerprints' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_APK_ASSETLINKS_SHA256', ''))))),
],
], ],
]; ];

View File

@@ -1,195 +0,0 @@
# Dewemoji Billing Integration Plan (QRIS + PayPal)
This document outlines a proper, production-grade billing flow for Dewemoji using **QRIS (Pakasir)** and **PayPal Subscriptions**, including webhooks, retries, and license activation.
---
## 1) Goals
- Replace primitive payment links with real provider integrations.
- Support **subscription** billing (monthly/annual) and **one-time lifetime**.
- Activate or revoke licenses based on webhook-confirmed payments.
- Log all webhook events and payment activity for audit.
---
## 2) Data Model
### `orders` (new)
Acts as the primary record of what the user is buying. Payments link back to orders.
- `id`
- `user_id`
- `plan_code`
- `type` (`one_time`, `subscription`)
- `currency` (`IDR`, `USD`)
- `amount`
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
- `provider` (`qris`, `paypal`)
- `provider_ref`
- `created_at`, `updated_at`
### `payments` (new)
- `id`
- `user_id`
- `order_id`
- `provider` (`qris`, `paypal`)
- `type` (`one_time`, `subscription`)
- `plan_code` (`personal_monthly`, `personal_annual`, `personal_lifetime`)
- `currency` (`IDR`, `USD`)
- `amount`
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
- `provider_ref` (invoice_id / order_id / subscription_id)
- `raw_payload` (json)
- `created_at`, `updated_at`
### `subscriptions` (existing)
Extend with:
- `provider`
- `provider_ref`
- `status` (`active`, `pending`, `canceled`, `expired`)
- `started_at`, `expires_at`, `canceled_at`
- `next_renewal_at` (optional)
### `webhook_events` (existing)
Continue to log inbound payloads and processing status:
- `provider`, `event_type`, `status`, `payload`, `received_at`, `processed_at`, `error_message`
---
## 3) Payment Flow (User Journey)
### Pricing Page (Frontend)
Each plan shows:
- **Primary currency** (based on geo + user toggle)
- **Two payment buttons** (real provider flow):
- **QRIS (IDR)** → subscription or one-time
- **PayPal (USD)** → subscription or one-time
### Backend Endpoints
#### QRIS (Pakasir)
- `POST /billing/qris/create`
- Creates invoice via Pakasir API
- Stores `payments` with `pending`
- Returns QR payment URL or QR code data
- `GET /billing/qris/return` (optional)
- Shows “pending / processing” state
#### PayPal Subscriptions
- `POST /billing/paypal/create`
- Creates PayPal subscription
- Stores `payments` with `pending`
- Returns approval URL
- `GET /billing/paypal/return`
- Shows “pending / processing” state
---
## 4) Webhook Processing (Critical)
Webhook endpoint:
```
POST /webhooks/{provider}
```
Store inbound payloads in `webhook_events`, then process async (queue).
### PayPal Events
- `BILLING.SUBSCRIPTION.ACTIVATED` → mark subscription active, set `users.tier = personal`
- `BILLING.SUBSCRIPTION.CANCELLED` → mark subscription canceled
- `PAYMENT.SALE.COMPLETED` → mark payment paid
- `PAYMENT.SALE.DENIED` → mark payment failed
### Pakasir / QRIS Events
- `payment.paid` → mark payment paid, grant access
- `payment.expired` → mark payment failed/expired
---
## 5) License Activation Rules
When a payment or subscription is confirmed:
- Create or update a `subscriptions` record
- Set `users.tier = personal`
- Store provider refs (`provider_ref`)
- Log admin audit record
When revoked/expired:
- Update `subscriptions.status`
- Downgrade user if no active subscription remains
### Renewal Logic (QRIS manual renew)
- **If still active:** extend from current `expires_at`
- `expires_at = expires_at + duration`
- **If expired:** start from now
- `expires_at = now + duration`
---
## 6) Admin Dashboard Enhancements
Add or extend:
- **Payments list** (new screen)
- filter by provider/status/currency
- show raw provider ref
- **Subscriptions list** (already exists)
- show provider + status
- **Webhook events** (already exists)
- replay capability
---
## 7) Security & Reliability
- Validate webhook signatures (PayPal + Pakasir)
- Reject duplicate events (idempotency)
- Use queues for webhook processing
- Log all webhook failures
---
## 8) Required Inputs (From Owner)
Before implementation:
1. **Pakasir API docs** (create invoice, webhook payload format)
2. **PayPal API credentials** (client_id, secret, webhook signing key)
3. Confirm **plans & pricing**:
- Monthly
- Annual
- Lifetime
---
## 9) Implementation Phases
**Phase 1 — Schema + Core Models**
- Add `orders` table
- Add `payments` table (link to orders)
- Extend `subscriptions`
- Update webhook model if needed
**Phase 2 — Provider APIs**
- Pakasir invoice create
- PayPal subscription create
**Phase 3 — Webhooks**
- Save raw events
- Process via queue + idempotency
**Phase 4 — UI**
- Pricing page buttons → real flows
- Admin payment + subscription tools
---
## 10) Notes
- This plan assumes **proper subscription lifecycle** with webhooks.
- PayPal.me / static links are **not sufficient** for subscriptions.
- All access control must be tied to **confirmed payment status**.

View File

@@ -1,143 +0,0 @@
# Dewemoji User Dashboard Implementation Plan
**Source:** `dewemoji-ux-flow-brief.md`
**Goal:** Build the Personal user dashboard + inline personalization flows aligned with the UX brief.
---
## 1) Scope & Principles
- **Primary flow**: Add keywords directly on emoji detail pages (fast, contextual).
- **Secondary flow**: Manage keywords in the dashboard (bulk + power tools).
- **Zero friction** for Visitors/Free users; gentle upgrade prompts.
- **Shared layout** with admin (same shell, role-based sidebar).
---
## 2) User States & Routing
### Visitor (non-logged)
- Public search + emoji detail only.
- CTA: “Sign up free” / “Upgrade to Personal”.
### Free user (logged, no subscription)
- See public content, “Your keywords” section locked.
- Upgrade nudges on detail + empty states in dashboard.
### Personal user (paid)
- Full access: quick add on detail + dashboard CRUD.
---
## 3) UI Screens (User Dashboard)
### 3.1 Dashboard shell (shared)
- Same layout as admin (sidebar, top bar).
- Role-based sidebar menu:
- Overview
- My Keywords
- API Keys
- Billing
- Preferences
- Support / Logout
### 3.2 Overview (user)
- Show:
- Total keywords
- Recent keyword additions (last 7 days)
- Synced devices (optional later)
- Small quick link to “My Keywords”.
### 3.3 My Keywords (primary management)
- Table: Emoji | Your keywords | Language | Actions
- Toolbar:
- + Add Keyword
- Import JSON
- Export JSON
- Search/filter
- Modal: emoji picker → add keywords + language.
### 3.4 API Keys
- List user API keys
- Create/revoke key
### 3.5 Billing
- Current plan + renewal / expiry
- Payment method (future)
- Upgrade CTA if free
### 3.6 Preferences
- Theme
- Tone lock / preferred skin tone (future)
- Locale
---
## 4) Site Page Enhancements (Non-dashboard)
### 4.1 Emoji Detail Page (critical)
- Show public keywords for everyone.
- If Personal: show “Your Keywords” list + quick add modal.
- If Free: show locked section + upgrade CTA.
- If Visitor: CTA to sign up.
### 4.2 Search Results Page
- Personal user: blend public + user keywords.
- Show “Your keyword” badge and “quick edit” button.
---
## 5) API Endpoints (v1)
### Keyword CRUD
- `GET /v1/emoji/{slug}?include_user_keywords=true`
- `POST /v1/keywords` (add keyword)
- `PUT /v1/keywords/{id}` (edit)
- `DELETE /v1/keywords/{id}`
- `GET /v1/keywords` (list user keywords)
### User keyword import/export
- `POST /v1/keywords/import`
- `GET /v1/keywords/export`
### Dashboard data
- `GET /v1/user/summary` (counts + recents)
- `GET /v1/user/apikeys` / `POST /v1/user/apikeys`
- `GET /v1/user/billing` (subscription status)
---
## 6) Database / Models
Existing:
- `user_keywords`
- `subscriptions`
Add if needed:
- `user_keyword_imports` (optional audit)
---
## 7) Implementation Phases
### Phase A — Foundation
- Add user routes + dashboard views
- Layout reuse with rolebased sidebar
### Phase B — Keywords UX
- Detail page quick add
- Dashboard keyword CRUD + import/export
### Phase C — Billing & API Keys
- Billing summary + upgrade CTA
- API key list + create/revoke
---
## 8) Acceptance Criteria
- Personal user can add keywords from detail page in <5 seconds.
- Keyword appears in search results immediately.
- Dashboard keyword table supports filter + edit + delete.
- Free users see upgrade prompts, not broken UI.

View File

@@ -89,6 +89,9 @@
<div class="sticky top-0 z-40 -mx-4 px-4 py-3 mb-6 bg-[var(--app-bg)]/90 backdrop-blur border-b border-white/10 sm:static sm:mx-0 sm:px-0 sm:py-0 sm:mb-8 sm:border-0"> <div class="sticky top-0 z-40 -mx-4 px-4 py-3 mb-6 bg-[var(--app-bg)]/90 backdrop-blur border-b border-white/10 sm:static sm:mx-0 sm:px-0 sm:py-0 sm:mb-8 sm:border-0">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-xs sm:text-sm text-gray-500 font-mono"> <div class="flex items-center gap-2 text-xs sm:text-sm text-gray-500 font-mono">
<button type="button" onclick="window.history.length > 1 ? window.history.back() : (window.location.href='{{ route('home') }}')" class="inline-flex sm:hidden w-8 h-8 rounded-full bg-white/5 border border-white/10 items-center justify-center text-gray-300 hover:text-white">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
</button>
<a href="{{ route('home') }}" class="hover:text-white transition-colors">Home</a> <a href="{{ route('home') }}" class="hover:text-white transition-colors">Home</a>
<i data-lucide="chevron-right" class="w-3 h-3"></i> <i data-lucide="chevron-right" class="w-3 h-3"></i>
<span class="hidden sm:inline-flex hover:text-white">{{ $category }}</span> <span class="hidden sm:inline-flex hover:text-white">{{ $category }}</span>
@@ -108,8 +111,12 @@
<div class="glass-card rounded-[32px] aspect-square flex items-center justify-center relative overflow-hidden group"> <div class="glass-card rounded-[32px] aspect-square flex items-center justify-center relative overflow-hidden group">
<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="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 id="emoji-hero-symbol" class="text-[140px] md:text-[180px] leading-none select-none relative z-10 animate-float drop-shadow-2xl">{{ $symbol }}</div> <div id="emoji-hero-symbol" 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"> <div class="absolute bottom-6 flex gap-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all transform translate-y-0 sm:translate-y-2 sm:group-hover:translate-y-0">
<button onclick="copyCurrentEmoji()" 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"> <button id="favorite-toggle-btn" type="button" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-yellow-400/20 transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Add Favorite" aria-label="Add Favorite">
<span id="favorite-toggle-icon" class="text-yellow-300 text-lg leading-none"></span>
<span id="favorite-toggle-label" class="sr-only">Add Favorite</span>
</button>
<button onclick="copyCurrentEmoji()" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Copy">
<i data-lucide="copy" class="w-5 h-5 force-white"></i> <i data-lucide="copy" class="w-5 h-5 force-white"></i>
</button> </button>
</div> </div>
@@ -147,20 +154,14 @@
<div> <div>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<span class="bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 px-3 py-1 rounded-full text-xs font-bold uppercase">{{ $subcategory }}</span> <span class="bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 px-3 py-1 rounded-full text-xs font-bold uppercase">{{ $subcategory }}</span>
<span id="favorite-pill" class="hidden bg-yellow-500/10 text-yellow-300 border border-yellow-500/30 px-3 py-1 rounded-full text-xs font-bold uppercase">Favorite</span>
</div> </div>
<h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1> <h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1>
<p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p> <p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p>
</div> </div>
<div class="flex gap-4">
<button id="copy-current-emoji-btn" onclick="copyCurrentEmoji()" 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>
@if($supportsTone) @if($supportsTone)
<div class="glass-card rounded-xl p-3"> <div id="tone-panel" class="glass-card rounded-xl p-3">
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div> <div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button> <button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button>
@@ -284,7 +285,7 @@
</div> </div>
<div id="toast" class="fixed bottom-10 left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none"> <div id="toast" class="fixed left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none" style="bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem);">
<div class="bg-brand-ocean text-white px-6 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2"> <div class="bg-brand-ocean text-white px-6 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2">
<i data-lucide="check" class="w-4 h-4"></i> <i data-lucide="check" class="w-4 h-4"></i>
<span id="toast-msg">Copied!</span> <span id="toast-msg">Copied!</span>
@@ -328,9 +329,161 @@
@push('scripts') @push('scripts')
<script> <script>
const RECENT_KEY = 'dewemoji_recent'; const RECENT_KEY = 'dewemoji_recent';
const FAVORITES_KEY = 'dewemoji_favorites';
const TONE_STORAGE_KEY = 'dewemoji_skin_tone'; const TONE_STORAGE_KEY = 'dewemoji_skin_tone';
const SYMBOL_DEFAULT = @json($symbol); const SYMBOL_DEFAULT = @json($symbol);
const DETAIL_SLUG = @json($slug);
const DETAIL_NAME = @json($name);
const DETAIL_CATEGORY = @json($category);
const DETAIL_SUBCATEGORY = @json($subcategory);
const DETAIL_SUPPORTS_TONE = @json($supportsTone);
const DETAIL_VARIANTS_LIST = @json(array_values($toneVariants));
const TONE_VARIANTS = @json($toneVariants); const TONE_VARIANTS = @json($toneVariants);
const TONE_CHAR_BY_SLUG = {
light: '\u{1F3FB}',
'medium-light': '\u{1F3FC}',
medium: '\u{1F3FD}',
'medium-dark': '\u{1F3FE}',
dark: '\u{1F3FF}',
};
const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]);
const MODIFIABLE_BASES = new Set([
0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476,
0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926,
]);
const NOT_TONEABLE_BASES = new Set([
0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4,
0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6,
0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2,
]);
const NON_TONEABLE_NAME_PATTERNS = [
/\bspeaking head\b/i,
/\bbust in silhouette\b/i,
/\bbusts in silhouette\b/i,
/\bfootprints?\b/i,
/\bfingerprint\b/i,
/\bfamily\b/i,
/\bpeople hugging\b/i,
/\bpeople with bunny ears\b/i,
/\bpeople wrestling\b/i,
/\bpeople fencing\b/i,
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
/\bmerman\b/i,
/\bmermaid\b/i,
/\bdeaf man\b/i,
/\bdeaf woman\b/i,
/\bman: beard\b/i,
/\bwoman: beard\b/i,
/\bperson running facing right\b/i,
/\bperson walking facing right\b/i,
/\bperson kneeling facing right\b/i,
];
const ROLE_TONEABLE_NAME_PATTERNS = [
/\btechnologist\b/i,
/\bscientist\b/i,
/\boffice worker\b/i,
/\bfactory worker\b/i,
/\bmechanic\b/i,
/\bcook\b/i,
/\bfarmer\b/i,
/\bjudge\b/i,
/\bteacher\b/i,
/\bstudent\b/i,
/\bhealth worker\b/i,
/\bsinger\b/i,
/\bastronaut\b/i,
/\bfirefighter\b/i,
/\bfacepalming\b/i,
/\bdancing\b/i,
/\bdetective\b/i,
/\bfencing\b/i,
/\bbouncing ball\b/i,
/\blifting weights\b/i,
/\bwrestling\b/i,
];
const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']);
const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
];
const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const IS_ANDROID_RENDERER = (() => {
try {
if (/Android/i.test(navigator.userAgent || '')) return true;
const platform = window.Capacitor?.getPlatform?.();
return platform === 'android';
} catch (_) {
return /Android/i.test(navigator.userAgent || '');
}
})();
function twemojiSvgUrlFromCodepoints(cp) {
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
}
function notoEmojiSvgUrlFromCodepoints(cp) {
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`;
}
function shouldUseDetailToneImageFallback(emojiStr, tone = 'off') {
if (!IS_ANDROID_RENDERER) return false;
if (!tone || tone === 'off') return false;
if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(String(DETAIL_SLUG || '').toLowerCase())) return false;
return /\u200D/.test(String(emojiStr || ''));
}
function renderDetailHeroEmoji(emojiStr, tone = 'off') {
const hero = document.getElementById('emoji-hero-symbol');
if (!hero) return;
const glyph = String(emojiStr || '');
if (!glyph) {
hero.textContent = '';
return;
}
if (!shouldUseDetailToneImageFallback(glyph, tone) || !window.twemoji?.convert?.toCodePoint) {
hero.textContent = glyph;
return;
}
let cp = '';
try {
cp = window.twemoji.convert.toCodePoint(glyph);
} catch (_) {
hero.textContent = glyph;
return;
}
if (!cp) {
hero.textContent = glyph;
return;
}
const img = new Image();
img.alt = glyph;
img.draggable = false;
img.decoding = 'async';
img.className = 'inline-block align-middle select-none';
img.style.width = '1em';
img.style.height = '1em';
img.style.objectFit = 'contain';
img.onerror = () => {
img.onerror = () => {
hero.textContent = glyph;
};
img.src = twemojiSvgUrlFromCodepoints(cp);
};
img.src = notoEmojiSvgUrlFromCodepoints(cp);
hero.replaceChildren(img);
}
let currentDisplayEmoji = SYMBOL_DEFAULT; let currentDisplayEmoji = SYMBOL_DEFAULT;
function getStoredTone() { function getStoredTone() {
@@ -343,13 +496,17 @@ function setStoredTone(value) {
function emojiByTone(tone) { function emojiByTone(tone) {
if (!tone || tone === 'off') return SYMBOL_DEFAULT; if (!tone || tone === 'off') return SYMBOL_DEFAULT;
return TONE_VARIANTS[tone] || SYMBOL_DEFAULT; if (!isDetailToneAllowed()) return stripSkinTone(SYMBOL_DEFAULT) || SYMBOL_DEFAULT;
const toneChar = TONE_CHAR_BY_SLUG[tone];
if (!toneChar) return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
const base = stripSkinTone(SYMBOL_DEFAULT);
if (!canApplyToneTo(base)) return base;
return applyToneSmart(base, toneChar) || TONE_VARIANTS[tone] || base || SYMBOL_DEFAULT;
} }
function applyTone(tone) { function applyTone(tone) {
currentDisplayEmoji = emojiByTone(tone); currentDisplayEmoji = emojiByTone(tone);
const hero = document.getElementById('emoji-hero-symbol'); renderDetailHeroEmoji(currentDisplayEmoji, tone);
if (hero) hero.textContent = currentDisplayEmoji;
document.querySelectorAll('.tone-chip').forEach((chip) => { document.querySelectorAll('.tone-chip').forEach((chip) => {
const active = chip.dataset.tone === tone; const active = chip.dataset.tone === tone;
chip.classList.toggle('bg-brand-ocean/20', active); chip.classList.toggle('bg-brand-ocean/20', active);
@@ -360,22 +517,243 @@ function applyTone(tone) {
function loadRecent() { function loadRecent() {
try { try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); const parsed = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter(isRecentEmojiToken) : [];
} catch { } catch {
return []; return [];
} }
} }
function isRecentEmojiToken(value) {
const s = String(value || '').trim();
if (!s) return false;
if (s.length > 24) return false;
if (/[:;&<#\\]/.test(s)) return false;
try {
return /\p{Extended_Pictographic}/u.test(s);
} catch {
return /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s);
}
}
function isDetailNameNonToneable() {
return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
}
function isDetailRoleToneable() {
return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
}
function isDetailToneAllowed() {
if (!DETAIL_SUPPORTS_TONE) return false;
const category = String(DETAIL_CATEGORY || '').toLowerCase();
if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false;
if (isDetailNameNonToneable()) return false;
if (IS_ANDROID_RENDERER) {
const slug = String(DETAIL_SLUG || '').toLowerCase();
const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug);
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false;
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''))) return false;
}
const base = stripSkinTone(SYMBOL_DEFAULT);
if (!base || !canApplyToneTo(base)) return false;
const name = String(DETAIL_NAME || '').toLowerCase();
const looksGenderedRole =
category === 'people & body' &&
(name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name));
if (looksGenderedRole && !isDetailRoleToneable()) return false;
return true;
}
function stripSkinTone(emojiChar) {
return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
}
function containsZWJ(s) {
return /\u200D/.test(String(s || ''));
}
function countHumans(s) {
let n = 0;
for (const ch of Array.from(String(s || ''))) {
const cp = ch.codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++;
}
return n;
}
function canApplyToneTo(s) {
if (!s) return false;
try {
const cp0 = String(s).codePointAt(0);
if (NOT_TONEABLE_BASES.has(cp0)) return false;
} catch (_) {}
if (!containsZWJ(s)) return true;
return countHumans(s) <= 2;
}
function toCodePoints(str) {
const out = [];
for (const ch of String(str || '')) out.push(ch.codePointAt(0));
return out;
}
function fromCodePoints(arr) {
return String.fromCodePoint(...arr);
}
function applyToneSmart(emojiChar, toneChar) {
if (!emojiChar || !toneChar) return emojiChar;
const toneCp = String(toneChar).codePointAt(0);
if (!TONE_CPS.has(toneCp)) return emojiChar;
const cps = toCodePoints(emojiChar);
for (const cp of cps) {
if (TONE_CPS.has(cp)) return emojiChar;
}
const VS16 = 0xFE0F;
const ZWJ = 0x200D;
const idxs = [];
for (let i = 0; i < cps.length; i += 1) {
if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i);
}
if (idxs.length === 0) {
if (cps.includes(ZWJ)) return emojiChar;
let pos = 1;
let rightStart = pos;
if (cps[1] === VS16) {
rightStart = 2;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
if (idxs.length === 2) {
const out = cps.slice();
const insertAfter = (baseIdx) => {
let pos = baseIdx + 1;
if (out[pos] === VS16) out.splice(pos, 1);
out.splice(pos, 0, toneCp);
};
insertAfter(idxs[1]);
insertAfter(idxs[0]);
return fromCodePoints(out);
}
let pos = idxs[0] + 1;
let rightStart = pos;
if (cps[pos] === VS16) {
rightStart = pos + 1;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
function saveRecent(items) { function saveRecent(items) {
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, 8))); const clean = items.filter(isRecentEmojiToken);
localStorage.setItem(RECENT_KEY, JSON.stringify(clean.slice(0, 8)));
} }
function addRecent(emoji) { function addRecent(emoji) {
if (!isRecentEmojiToken(emoji)) return;
const curr = loadRecent().filter((e) => e !== emoji); const curr = loadRecent().filter((e) => e !== emoji);
curr.unshift(emoji); curr.unshift(emoji);
saveRecent(curr); saveRecent(curr);
} }
function loadFavorites() {
try {
const parsed = JSON.parse(localStorage.getItem(FAVORITES_KEY) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => item && typeof item === 'object')
.map((item) => ({
slug: String(item.slug || '').trim(),
emoji: String(item.emoji || '').trim(),
name: String(item.name || '').trim(),
category: String(item.category || '').trim(),
subcategory: String(item.subcategory || '').trim(),
supports_skin_tone: Boolean(item.supports_skin_tone),
variants: Array.isArray(item.variants) ? item.variants : [],
}))
.filter((item) => item.slug !== '' && isRecentEmojiToken(item.emoji));
} catch {
return [];
}
}
function saveFavorites(items) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items.slice(0, 24)));
}
function isCurrentFavorite() {
return loadFavorites().some((item) => item.slug === DETAIL_SLUG);
}
function renderFavoriteButton() {
const btn = document.getElementById('favorite-toggle-btn');
const icon = document.getElementById('favorite-toggle-icon');
const label = document.getElementById('favorite-toggle-label');
const pill = document.getElementById('favorite-pill');
if (!btn || !icon || !label) return;
const active = isCurrentFavorite();
icon.textContent = active ? '★' : '☆';
icon.classList.toggle('text-yellow-300', true);
icon.classList.toggle('text-gray-300', !active);
label.textContent = active ? 'Favorited' : 'Add Favorite';
btn.title = active ? 'Remove Favorite' : 'Add Favorite';
btn.setAttribute('aria-label', active ? 'Remove Favorite' : 'Add Favorite');
btn.classList.toggle('border-yellow-400/30', active);
btn.classList.toggle('bg-yellow-400/10', active);
if (pill) {
pill.classList.toggle('hidden', !active);
}
}
function toggleCurrentFavorite() {
const current = loadFavorites();
const remaining = current.filter((item) => item.slug !== DETAIL_SLUG);
const isRemoving = remaining.length !== current.length;
if (isRemoving) {
saveFavorites(remaining);
renderFavoriteButton();
if (typeof showToast === 'function') showToast('Removed from favorites');
else showDetailToast('Removed from favorites');
return false;
}
remaining.unshift({
slug: DETAIL_SLUG,
emoji: currentDisplayEmoji,
name: DETAIL_NAME,
category: DETAIL_CATEGORY,
subcategory: DETAIL_SUBCATEGORY,
supports_skin_tone: Boolean(DETAIL_SUPPORTS_TONE),
variants: Array.isArray(DETAIL_VARIANTS_LIST) ? DETAIL_VARIANTS_LIST : [],
});
saveFavorites(remaining);
renderFavoriteButton();
if (typeof showToast === 'function') showToast('Added to favorites');
else showDetailToast('Added to favorites');
return true;
}
function showDetailToast(message) {
const toast = document.getElementById('toast');
const msg = document.getElementById('toast-msg');
if (!toast || !msg) return;
msg.innerText = message;
toast.classList.remove('translate-y-24', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-24', 'opacity-0');
}, 1500);
}
function copyCurrentEmoji() { function copyCurrentEmoji() {
copyToClipboard(currentDisplayEmoji); copyToClipboard(currentDisplayEmoji);
} }
@@ -383,13 +761,7 @@ function copyCurrentEmoji() {
function copyToClipboard(text) { function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
addRecent(text); addRecent(text);
const toast = document.getElementById('toast'); showDetailToast(`Copied ${text}`);
const msg = document.getElementById('toast-msg');
msg.innerText = `Copied ${text}`;
toast.classList.remove('translate-y-24', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-24', 'opacity-0');
}, 1500);
}); });
} }
@@ -400,6 +772,12 @@ document.addEventListener('keydown', (e) => {
}); });
(() => { (() => {
const tonePanel = document.getElementById('tone-panel');
if (!isDetailToneAllowed()) {
if (tonePanel) tonePanel.classList.add('hidden');
applyTone('off');
return;
}
const initialTone = getStoredTone(); const initialTone = getStoredTone();
applyTone(initialTone); applyTone(initialTone);
document.querySelectorAll('.tone-chip').forEach((chip) => { document.querySelectorAll('.tone-chip').forEach((chip) => {
@@ -411,6 +789,9 @@ document.addEventListener('keydown', (e) => {
}); });
})(); })();
document.getElementById('favorite-toggle-btn')?.addEventListener('click', toggleCurrentFavorite);
renderFavoriteButton();
// Treat opening the single-emoji page as a "recently viewed emoji" event. // Treat opening the single-emoji page as a "recently viewed emoji" event.
addRecent(currentDisplayEmoji); addRecent(currentDisplayEmoji);

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ Route::post('/pricing/currency', [SiteController::class, 'setPricingCurrency'])-
Route::get('/download', [SiteController::class, 'download'])->name('download'); Route::get('/download', [SiteController::class, 'download'])->name('download');
Route::get('/downloads/version.json', [SiteController::class, 'downloadVersionJson'])->name('downloads.version'); Route::get('/downloads/version.json', [SiteController::class, 'downloadVersionJson'])->name('downloads.version');
Route::get('/downloads/dewemoji-latest.apk', [SiteController::class, 'downloadLatestApk'])->name('downloads.latest-apk'); Route::get('/downloads/dewemoji-latest.apk', [SiteController::class, 'downloadLatestApk'])->name('downloads.latest-apk');
Route::get('/.well-known/assetlinks.json', [SiteController::class, 'assetLinks'])->name('assetlinks');
Route::get('/support', [SiteController::class, 'support'])->name('support'); Route::get('/support', [SiteController::class, 'support'])->name('support');
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy'); Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
Route::get('/terms', [SiteController::class, 'terms'])->name('terms'); Route::get('/terms', [SiteController::class, 'terms'])->name('terms');

View File

@@ -15,6 +15,9 @@ class SitePagesTest extends TestCase
config()->set('dewemoji.apk_release.r2_public_base_url', 'https://downloads.example.com'); config()->set('dewemoji.apk_release.r2_public_base_url', 'https://downloads.example.com');
config()->set('dewemoji.apk_release.r2_keys.latest_apk', 'apk/dewemoji-latest.apk'); config()->set('dewemoji.apk_release.r2_keys.latest_apk', 'apk/dewemoji-latest.apk');
config()->set('dewemoji.apk_release.r2_keys.version_json', 'apk/version.json'); config()->set('dewemoji.apk_release.r2_keys.version_json', 'apk/version.json');
config()->set('dewemoji.apk_release.assetlinks.fingerprints', [
'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99',
]);
} }
public function test_core_pages_are_available(): void public function test_core_pages_are_available(): void
@@ -54,4 +57,19 @@ class SitePagesTest extends TestCase
->assertStatus(302) ->assertStatus(302)
->assertRedirect('https://downloads.example.com/apk/dewemoji-latest.apk'); ->assertRedirect('https://downloads.example.com/apk/dewemoji-latest.apk');
} }
public function test_assetlinks_endpoint_is_available(): void
{
$this->get('/.well-known/assetlinks.json')
->assertOk()
->assertJson([
[
'relation' => ['delegate_permission/common.handle_all_urls'],
'target' => [
'namespace' => 'android_app',
'package_name' => 'com.dewemoji.app',
],
],
]);
}
} }

View File

@@ -1,129 +0,0 @@
# Billing Cooldown Runtime Checklist (Staging)
## Scope
Validate these behaviors end-to-end:
1. `pending_cooldown` lock on new checkout (120s)
2. Continue pending payment via `Pay` button
3. PayPal/Pakasir webhook delay handling
4. Auto-status transition from `pending` to `paid`
## Preconditions
1. Staging is deployed with latest billing changes.
2. Config is refreshed:
- `php artisan optimize:clear`
- `php artisan config:cache`
3. Env includes:
- `DEWEMOJI_CHECKOUT_PENDING_COOLDOWN=120`
4. Webhooks are configured and reachable:
- PayPal webhook endpoint
- Pakasir webhook endpoint
5. Test account available (verified email, logged in).
## Quick Observability
1. Browser devtools open (`Network` tab).
2. Keep Billing page open in another tab for status checks.
3. Optional server logs tail:
- `tail -f storage/logs/laravel.log`
## Test Matrix
### A. Cooldown Lock (PayPal)
1. Start Personal checkout in USD.
2. Before webhook settles, try starting another checkout from Pricing.
3. Expected:
- API returns `409` with `error: pending_cooldown`.
- UI shows wait message with countdown (`retry_after` seconds).
- Checkout CTA stays disabled until countdown reaches 0.
### B. Cooldown Lock (Pakasir)
1. Start Personal checkout in IDR (QRIS modal opens).
2. Close/cancel modal only if needed for this test case.
3. Immediately try another new checkout from Pricing.
4. Expected:
- API returns `409 pending_cooldown`.
- UI countdown appears and blocks new checkout.
### C. Continue Pending (PayPal)
1. Create a PayPal pending payment.
2. Go to `/dashboard/billing`.
3. Click `Pay` on pending row.
4. Expected:
- Redirect to PayPal approve URL.
- No new payment row created just for resume action.
### D. Continue Pending (Pakasir)
1. Create a Pakasir pending payment.
2. Go to `/dashboard/billing`.
3. Click `Pay` on pending row.
4. Expected:
- QR modal opens with amount + expiry.
- Polling runs and closes modal when status becomes paid.
### E. Webhook Delay UX
1. Complete payment at provider.
2. Observe app while webhook is delayed.
3. Expected:
- Status may remain `pending` briefly.
- User can continue pending (`Pay` button).
- After webhook/poll settles: status updates to `paid`.
### F. Pending Timeout to New Checkout
1. Keep a payment pending beyond cooldown (`>=120s`).
2. Try new checkout from Pricing.
3. Expected:
- New checkout is allowed again.
- Existing behavior for replacing/canceling pending remains intact.
### G. Edge Responses
1. Try `Pay` on a row that is no longer pending (race condition).
2. Expected:
- UI handles response (`payment_not_pending`) and refreshes state safely.
3. If pending QR is expired:
- Expected `payment_expired`, prompt to start new checkout.
## API Assertions (Network)
Check response payloads:
1. Cooldown block:
```json
{
"error": "pending_cooldown",
"retry_after": 87,
"pending_payment_id": 123,
"provider": "paypal"
}
```
2. Resume PayPal:
```json
{
"ok": true,
"mode": "redirect",
"approve_url": "https://..."
}
```
3. Resume Pakasir:
```json
{
"ok": true,
"mode": "qris",
"payment_number": "...",
"order_id": "DW-...",
"expired_at": "..."
}
```
## Pass/Fail Sheet
| Check | Result | Notes |
|---|---|---|
| A. PayPal cooldown lock | | |
| B. Pakasir cooldown lock | | |
| C. Resume pending PayPal | | |
| D. Resume pending Pakasir | | |
| E. Webhook delay UX | | |
| F. New checkout after cooldown | | |
| G. Edge response handling | | |
## Exit Criteria
Release-ready when:
1. All rows above are `PASS`.
2. No uncaught JS errors in console.
3. No unexpected 5xx in network log during flow.

View File

@@ -1,58 +0,0 @@
# Billing Mode Switch (`sandbox` -> `live`)
This project supports two license verification modes via env:
- `DEWEMOJI_BILLING_MODE=sandbox`
Any non-empty license key is treated as valid Pro.
- `DEWEMOJI_BILLING_MODE=live`
Key must pass live validation rules (`DEWEMOJI_LICENSE_ACCEPT_ALL`, `DEWEMOJI_PRO_KEYS`, or provider validation).
## Recommended local setup
Use sandbox while building:
```env
DEWEMOJI_BILLING_MODE=sandbox
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_PRO_KEYS=
```
## Staging/live setup
Use live mode:
```env
DEWEMOJI_BILLING_MODE=live
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_PRO_KEYS=key_1,key_2,key_3
DEWEMOJI_VERIFY_CACHE_TTL=300
DEWEMOJI_GUMROAD_ENABLED=true
DEWEMOJI_GUMROAD_PRODUCT_IDS=prod_abc123
DEWEMOJI_MAYAR_ENABLED=false
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
DEWEMOJI_MAYAR_SECRET_KEY=
```
## Provider notes
- Gumroad validation uses configured `DEWEMOJI_GUMROAD_VERIFY_URL` + first `DEWEMOJI_GUMROAD_PRODUCT_IDS`.
- Mayar validation uses `DEWEMOJI_MAYAR_VERIFY_URL` + `DEWEMOJI_MAYAR_API_KEY`.
- Or use `DEWEMOJI_MAYAR_API_BASE` + `DEWEMOJI_MAYAR_ENDPOINT_VERIFY` + `DEWEMOJI_MAYAR_SECRET_KEY`.
- For local QA (no external billing call), you can define:
- `DEWEMOJI_GUMROAD_TEST_KEYS=dev_key_1,dev_key_2`
- `DEWEMOJI_MAYAR_TEST_KEYS=dev_key_3`
## API endpoints affected
- `POST /v1/license/verify`
- `POST /v1/license/activate`
- `POST /v1/license/deactivate`
- Tier-aware API access such as `GET /v1/emojis` (free/pro behavior)
## Notes
- Current provider integration is baseline and safe-fallback (`false` on network/API mismatch).
- Keep `DEWEMOJI_PRO_KEYS` for emergency fallback during migration cutover.
- `POST /v1/license/verify` includes provider fields on success: `source`, `plan`, `product_id`, `expires_at`.
- Invalid live checks include `details.gumroad` and `details.mayar` for diagnostics.

View File

@@ -1,124 +0,0 @@
# Dewemoji Community Plan (PrivateFirst → Public Consensus)
This plan matches the core purpose: **help people find emojis with their own words, in any language**, and let the community optionally promote keywords into global search.
## Core principles
- **Private first:** users can store any keyword for their own use.
- **Public by choice:** users can propose a keyword to the public pool.
- **Consensusbased:** public keywords only become searchable after votes.
- **Languagefriendly:** keywords can be in any language/script (not only Latin).
- **Safetyaware:** moderation applies only to public proposals (not private).
## Current language reality
- Today the dataset only includes **EN + ID** keywords for all emojis.
- Community should be designed to add **other languages** over time.
## Twolayer keyword model
### 1) Private keywords (personal)
- Stored per user (or device if not logged in).
- Any term is allowed, even if not “correct.”
- Used only for that users search + recall.
- No moderation needed.
- Created and managed in the **user dashboard** (not on emoji detail pages).
- Must be **used locally first** before being eligible for public proposal.
### 2) Public keywords (community)
- Users can propose **one of their own private keywords** to be public **from the dashboard**.
- Emoji detail pages only **display and vote** on public proposals; no direct proposal UI there.
- Once the proposal passes the threshold, it becomes globally searchable.
## Public keyword lifecycle
```
private → public_pending → public
```
Suggested rules:
- **public_pending** shown publicly with vote controls.
- **public** if it reaches **≥ 5 upvotes** (your rule).
- Optional future: autoreject with heavy downvotes or time decay.
## Voting model
- 1 vote per user per keyword.
- Score = upvotes downvotes.
- Promotion rule: **>= 5 upvotes** (simple and clear).
- Optional future: trustweighted votes.
## Moderation model
- Moderation applies only to **public proposals**.
- Private keywords are always allowed.
- Blocking rules:
- disallowed words
- mismatched emoji meaning
- language mismatch (optional)
## UX flow (simple)
1) User adds private keyword.
2) User uses it locally (personal search/recall).
3) User clicks “Make Public” from the dashboard.
4) Keyword appears on emoji detail page as pending.
4) Community votes.
5) Reaches 5 upvotes → becomes public search term.
## API endpoints (clean, minimal set)
Auth:
- `POST /v1/contrib/auth/request`
- `POST /v1/contrib/auth/verify`
Keyword actions:
- `POST /v1/contrib/suggest`
- `POST /v1/contrib/make-public`
- `POST /v1/contrib/vote`
- `GET /v1/contrib/list?emoji=...`
- `GET /v1/contrib/search?q=...`
Moderation:
- `GET /v1/keywords/pending` (admin/moderation)
## Data model (minimum)
- `emoji_keywords` (keyword, lang, status, visibility, owner_user_id)
- `keyword_votes` (keyword_id, user_id, vote)
- `moderation_events`
- `users` / `magic_links`
## Optional recommendations (futuresafe)
### A) Multilingual expansion
- Encourage new languages by highlighting “empty language slots.”
- Allow region variants (e.g., `en-US` vs `en-GB`).
### B) Personal dictionary export
- Export private keywords per user.
### C) Trust tiers
- Contributors with good history can fasttrack proposals.
### D) Admin review console
- Moderators can approve/reject quickly.
### E) Abuse prevention
- Rate limit public proposals + voting.
- Use Turnstile only for public actions.
### F) Search blending
- Private keywords weighted highest for the owner.
- Public keywords weighted lower but global.
### G) “My Words” UI
- Show users private keywords in the **dashboard**.
- Onetap “Make Public” in the dashboard (not on detail page).
## Recommended rollout
1) Private keywords (local only).
2) Public proposals + votes (no AI).
3) Basic moderation + rate limits.
4) AI guard + trust tiers.

View File

@@ -1,74 +0,0 @@
# Current Local Database (SQLite)
This describes what exists **right now** in the rebuild app (`app/database/database.sqlite`).
## Local DB engine
- SQLite (file: `app/database/database.sqlite`)
## Tables present
Framework defaults:
- `cache`
- `cache_locks`
- `failed_jobs`
- `job_batches`
- `jobs`
- `migrations`
- `password_reset_tokens`
- `sessions`
- `users`
Dewemoji core tables:
- `licenses`
- `license_activations`
- `usage_logs`
## Current row counts (local)
- `emojis`: 2131
- `emoji_keywords`: 13420
- `emoji_aliases`: 0
- `emoji_shortcodes`: 0
- `licenses`: 7
- `license_activations`: 1
- `usage_logs`: 88
- `ai_guard_logs`: 54
- `ai_judgments`: 0
- `ai_lang_cache`: 6
- `ai_provider_usage`: 4
- `legacy_users`: 0
- `legacy_sessions`: 0
## What is *not* in local DB yet (from live SQL)
From `dewemoji-live-backend/dewemojiAPI_DB.sql`, these tables exist in live but are **still empty locally**:
- `emoji_aliases`
- `emoji_shortcodes`
- `ai_judgments`
- `legacy_users` (live users)
- `legacy_sessions` (live sessions)
## Why emojis still work locally
The rebuild app currently reads emojis from a **JSON dataset** (not DB):
- `app/data/emojis.json`
Thats why the UI works even though emoji tables arent present yet.
## Next step (if you want to migrate live SQL)
Migrations + importer are now in place. To sync everything locally (SQLite), run:
```bash
cd /Users/dwindown/Developments/dewemoji/app
php artisan migrate
php artisan dewemoji:import-live-sql /Users/dwindown/Developments/dewemoji-live-backend/dewemojiAPI_DB.sql --truncate
```
Notes:
- Live `users` + `sessions` go into `legacy_users` + `legacy_sessions` (so Laravel auth/session tables stay safe).
- Licenses/activations/usage_logs are mapped into the current tables for parity.
Just tell me which subset to migrate first.

View File

@@ -1,66 +1,53 @@
# Dewemoji Live Deployment Walkthrough # Dewemoji Operations Runbook
This is the production rollout checklist for the `dewemoji` app. This is the single operational guide for local, staging, and production workflows.
Use this in order: ## 1) Environment model
1. Prepare env
2. Deploy code
3. Run post-deploy commands
4. Ensure admin access
5. Verify billing/webhooks/search/auth
--- ### Local (safe default)
## 1) Pre-Deploy (Env + Infra)
Set these in live environment first:
```env ```env
APP_ENV=production APP_ENV=local
APP_DEBUG=false APP_DEBUG=true
APP_URL=https://your-live-domain.com APP_URL=http://127.0.0.1:8000
DB_CONNECTION=sqlite
DEWEMOJI_BILLING_MODE=sandbox
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
``` ```
Core requirements: ### Staging / Production
1. Database points to live DB (`DB_*`).
2. `APP_KEY` is set and stable.
3. `SESSION_DRIVER`, `CACHE_STORE`, `QUEUE_CONNECTION` configured.
4. Billing provider secrets are set (PayPal/Pakasir).
5. Allowed origins include live domain.
Recommended billing cooldown config: - Use `DEWEMOJI_BILLING_MODE=live`
- Configure real DB + provider credentials
- Keep `DEWEMOJI_LICENSE_ACCEPT_ALL=false`
For full production variable template, use `production-env.md`.
## 2) Live deployment sequence
### Pre-deploy
1. Confirm env values are set in server/Coolify.
2. Confirm webhook URLs are reachable:
- `https://<domain>/v1/paypal/webhook`
- `https://<domain>/webhooks/pakasir`
3. Recommended:
```env ```env
DEWEMOJI_BILLING_PENDING_COOLDOWN_SECONDS=120 DEWEMOJI_BILLING_PENDING_COOLDOWN_SECONDS=120
``` ```
Webhook URLs: ### Deploy code
1. PayPal webhook: `https://your-live-domain.com/v1/paypal/webhook`
2. Pakasir webhook: `https://your-live-domain.com/webhooks/pakasir`
---
## 2) Deploy Code
From your server app directory (example `/var/www/html`):
```bash ```bash
git fetch --all git fetch --all
git checkout main git checkout main
git pull origin main git pull origin main
```
Install dependencies if needed:
```bash
composer install --no-dev --optimize-autoloader composer install --no-dev --optimize-autoloader
``` ```
--- ### Post-deploy (required order)
## 3) Post-Deploy Commands (Required)
Run in this exact sequence:
```bash ```bash
php artisan optimize:clear php artisan optimize:clear
@@ -68,38 +55,23 @@ php artisan migrate --force
php artisan config:cache php artisan config:cache
``` ```
Optional but recommended: Optional:
```bash ```bash
php artisan route:cache php artisan route:cache
php artisan view:cache php artisan view:cache
```
If you use queue workers:
```bash
php artisan queue:restart php artisan queue:restart
``` ```
Check migration status: ## 3) Ensure admin access
```bash Promote existing user:
php artisan migrate:status
```
---
## 4) Ensure Admin User
Admin access is role-based (`users.role = admin`).
### Option A: Promote existing user (recommended)
```bash ```bash
php artisan tinker --execute="\App\Models\User::where('email','dewemoji@gmail.com')->update(['role'=>'admin']);" php artisan tinker --execute="\App\Models\User::where('email','dewemoji@gmail.com')->update(['role'=>'admin']);"
``` ```
### Option B: Create admin user if missing Or create missing admin:
```bash ```bash
php artisan tinker --execute=" php artisan tinker --execute="
@@ -112,43 +84,30 @@ php artisan tinker --execute="
" "
``` ```
Verify: ## 4) Live smoke tests
### Core routes
1. `/`
2. `/emoji/grinning-face`
3. `/pricing`
4. `/api-docs`
5. `/support`
6. `/privacy`
7. `/terms`
8. `/robots.txt`
9. `/sitemap.xml`
### API checks
```bash ```bash
php artisan tinker --execute="dump(\App\Models\User::where('email','dewemoji@gmail.com')->first(['id','email','role','tier']));" BASE=https://<domain>/v1
curl -s "$BASE/health" | jq .
curl -s "$BASE/categories" | jq 'keys | length'
curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length'
``` ```
--- ### Billing and webhook checks
## 5) Smoke Test Checklist (Live)
### A. Core App
1. Login/register works over HTTPS with no insecure form warnings.
2. Dashboard loads.
3. Discover search returns emojis.
4. Emoji detail page loads.
### B. Skin Tone
1. Discover: change skin tone selector and verify toneable emoji changes.
2. Detail: tone chips update hero emoji and copy behavior.
3. Refresh page: tone preference persists.
### C. Account + Keywords
1. Free account can create up to active limit.
2. Active/inactive keyword behavior reflected in search.
3. Private keyword search appears in Discover after creation.
### D. Billing
1. PayPal checkout starts and returns.
2. Pakasir QRIS starts and modal polls.
3. Pending payment can be resumed.
4. Cooldown prevents immediate repeated checkout spam.
### E. Webhooks
1. PayPal event recorded and payment status updates.
2. Pakasir event recorded and payment status updates.
Useful checks:
```bash ```bash
tail -n 200 storage/logs/laravel.log tail -n 200 storage/logs/laravel.log
@@ -156,66 +115,86 @@ php artisan tinker --execute="dump(\App\Models\WebhookEvent::latest()->take(10)-
php artisan tinker --execute="dump(\App\Models\Payment::latest()->take(10)->get(['id','provider','plan_code','status','created_at'])->toArray());" php artisan tinker --execute="dump(\App\Models\Payment::latest()->take(10)->get(['id','provider','plan_code','status','created_at'])->toArray());"
``` ```
--- ## 5) Billing runtime validation (staging)
## 6) Optional: PayPal Plan Sync (Admin) Verify these behaviors end-to-end:
From admin dashboard: 1. pending cooldown lock on repeat checkout attempts (`409 pending_cooldown`)
1. Open pricing admin page. 2. resume pending payment via dashboard `Pay`
2. Click sync PayPal plans. 3. webhook delay handling (`pending` -> `paid` transition)
3. Confirm plan IDs are written and no 500. 4. race/edge handling (`payment_not_pending`, `payment_expired`)
If there is failure, check: Minimum assertions:
- cooldown response includes `retry_after`
- resume PayPal returns `mode=redirect` + `approve_url`
- resume Pakasir returns `mode=qris` with expiry data
## 6) Staging SQL sync
Set MySQL connection env, then run in container:
```bash ```bash
tail -n 300 storage/logs/laravel.log | grep -Ei "paypal|sync|webhook|error|exception" cd /var/www/html
php artisan migrate
php artisan dewemoji:import-live-sql /var/www/html/dewemojiAPI_DB.sql --truncate
``` ```
--- Sanity check:
## 7) Extension Release Order
Release order:
1. Site/backend live first.
2. Verify API/auth on live domain.
3. Update extension default API base to live.
4. Publish extension update.
This avoids extension users hitting endpoints that are not ready.
---
## 8) APK Release (Direct Download)
APK release is independent from site redeploy.
Canonical URLs used by the app updater:
1. `https://dewemoji.com/downloads/version.json`
2. `https://dewemoji.com/downloads/dewemoji-latest.apk`
Set these env vars on app server:
```env
DEWEMOJI_APK_RELEASE_ENABLED=true
DEWEMOJI_APK_PUBLIC_BASE_URL=https://dewemoji.com/downloads
DEWEMOJI_R2_PUBLIC_BASE_URL=https://downloads.your-r2-domain.com
DEWEMOJI_R2_APK_VERSION_KEY=apk/version.json
DEWEMOJI_R2_APK_LATEST_KEY=apk/dewemoji-latest.apk
```
Validate redirects:
```bash ```bash
curl -I https://dewemoji.com/downloads/version.json php artisan tinker --execute="echo DB::table('emojis')->count().PHP_EOL;"
curl -I https://dewemoji.com/downloads/dewemoji-latest.apk php artisan tinker --execute="echo DB::table('emoji_keywords')->count().PHP_EOL;"
``` ```
--- Expected: emojis ~2131, emoji_keywords ~13420.
## 9) Rollback Strategy ## 7) MySQL GUI access via SSH tunnel
If release is broken: For internal-only Coolify MySQL, tunnel to container IP.
1. Re-deploy previous known-good git commit.
Resolve MySQL container IP:
```bash
MYSQL_IP=$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')")
echo "$MYSQL_IP"
```
Create tunnel:
```bash
ssh -N -L 3307:${MYSQL_IP}:3306 SERVER_USER@SERVER_HOST
```
Then connect in Sequel Ace/TablePlus using:
- Host `127.0.0.1`
- Port `3307`
- standard DB credentials
## 8) Local provider parity test (optional)
Switch local to live provider mode and verify with real keys.
```bash
BASE=http://127.0.0.1:8000/v1
curl -X POST "$BASE/license/verify" -H "Content-Type: application/json" -d '{"key":"<real_key>"}'
```
Also test activate/deactivate cycle, then return local env to sandbox mode.
## 9) APK release dependency note
App updater URLs used by the site:
- `https://dewemoji.com/downloads/version.json`
- `https://dewemoji.com/downloads/dewemoji-latest.apk`
For detailed APK build/release flow, use `dewemoji-apk-companion-build-walkthrough.md`.
## 10) Rollback
1. Re-deploy previous known-good commit.
2. Run: 2. Run:
```bash ```bash
@@ -224,4 +203,4 @@ php artisan config:cache
php artisan queue:restart php artisan queue:restart
``` ```
3. If issue is emoji dataset, use snapshot activation in admin catalog. 3. If issue is dataset-specific, switch to a known-good snapshot via admin tooling.

View File

@@ -1,115 +0,0 @@
# Dewemoji Admin Dashboard Walkthrough
This guide explains how to access and operate the admin dashboard, plus how each section is wired in the current build.
## Access
1. Sign in with an admin user (role `admin`).
2. Navigate to `/dashboard/admin/*` routes.
3. The sidebar automatically shows admin navigation when the session user is admin.
## Admin Sections
### Analytics
Route: `/dashboard/admin/analytics`
What it shows:
- User totals, active subscriptions, webhook counts
- Recent webhook events
Data source:
- Direct database counts via `AdminDashboardController::analytics()`
### Users
Route: `/dashboard/admin/users`
What it shows:
- User list (latest 50)
- Filters: search, tier, role
- Inline tier update (free/personal)
Actions:
- Update tier: POST `/dashboard/admin/users/tier`
### Subscriptions
Route: `/dashboard/admin/subscriptions`
What it shows:
- Subscription list (latest 50)
- Grant/revoke forms
Actions:
- Grant: POST `/dashboard/admin/subscriptions/grant`
- Revoke: POST `/dashboard/admin/subscriptions/revoke`
Notes:
- Granting an active subscription automatically sets user tier to `personal`.
- Revoking updates the user tier based on remaining active subscriptions.
### Pricing
Route: `/dashboard/admin/pricing`
What it shows:
- Pricing plans with editable fields
- Change log (latest 5)
Actions:
- Update pricing: POST `/dashboard/admin/pricing/update`
- Reset to defaults: POST `/dashboard/admin/pricing/reset`
Notes:
- Updates are stored in `pricing_plans`.
- Each change logs a snapshot to `pricing_changes`.
### Webhooks
Route: `/dashboard/admin/webhooks`
What it shows:
- Recent webhook events (latest 50)
- Replay action per event
Actions:
- Replay: POST `/dashboard/admin/webhooks/{id}/replay`
Notes:
- Replay marks the event as `pending`. (Actual processing should be handled by the webhook processor job/worker.)
### Settings
Route: `/dashboard/admin/settings`
What it shows:
- Current environment config summaries
- Editable settings for public access + maintenance mode
Actions:
- Update settings: POST `/dashboard/admin/settings/update`
Settings stored:
- `maintenance_enabled` (bool)
- `public_enforce` (bool)
- `public_origins` (array)
- `public_extension_ids` (array)
- `public_hourly_limit` (int)
## Admin Routes Reference
- GET `/dashboard/admin/analytics`
- GET `/dashboard/admin/users`
- POST `/dashboard/admin/users/tier`
- GET `/dashboard/admin/subscriptions`
- POST `/dashboard/admin/subscriptions/grant`
- POST `/dashboard/admin/subscriptions/revoke`
- GET `/dashboard/admin/pricing`
- POST `/dashboard/admin/pricing/update`
- POST `/dashboard/admin/pricing/reset`
- GET `/dashboard/admin/webhooks`
- POST `/dashboard/admin/webhooks/{id}/replay`
- GET `/dashboard/admin/settings`
- POST `/dashboard/admin/settings/update`
## Notes for Future Wiring
- Replace placeholder analytics charts with real metrics/graphs.
- Add pagination + sorting for users, subscriptions, and webhooks.
- Add confirmation dialogs for destructive actions.
- Wire replay to actual processor/job queue if needed.

View File

@@ -0,0 +1,171 @@
# Dewemoji APK Companion Runbook
This is the single guide for APK build, release, direct distribution, and versioning.
## 1) Scope
The Android companion uses:
- local bundled UI (`dewemoji-capacitor/src` -> `dewemoji-capacitor/www`)
- native floating bubble launcher (copy/search workflow)
- direct APK update via hosted `version.json`
The bubble is not an IME and does not perform direct cross-app insertion.
## 2) Prerequisites
1. Node.js `20+`
2. JDK `17`
3. Android SDK / Android Studio
4. `adb`
5. optional signing toolchain (`apksigner`)
Install deps once:
```bash
cd /Users/dwindown/Developments/dewemoji
npm --prefix dewemoji-capacitor install
```
## 3) Source and build model
Do not edit `dewemoji-capacitor/www/*` directly.
- edit source in `dewemoji-capacitor/src/*`
- generate runtime assets via:
```bash
npm --prefix dewemoji-capacitor run build
```
## 4) Debug build and device install
```bash
cd /Users/dwindown/Developments/dewemoji
npm --prefix dewemoji-capacitor run build
cd dewemoji-capacitor
npx cap sync android
cd android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
## 5) Release build
From repo root:
```bash
./scripts/apk/build-release.sh
```
Script behavior:
1. read `versionCode` and `versionName` from `android/app/build.gradle`
2. rebuild web assets
3. run `cap sync android`
4. build release APK
5. sign if signing env vars are present
6. output artifact under `dewemoji-capacitor/dist/apk/`
Output naming:
- `dewemoji-v{versionName}-{versionCode}.apk`
## 6) Signing env (optional but recommended)
```bash
export ANDROID_KEYSTORE_PATH="/absolute/path/release.jks"
export ANDROID_KEYSTORE_PASSWORD="..."
export ANDROID_KEY_ALIAS="..."
export ANDROID_KEY_PASSWORD="..."
```
## 7) Direct release to Cloudflare R2
Required env:
```bash
export R2_ACCOUNT_ID="..."
export R2_ACCESS_KEY_ID="..."
export R2_SECRET_ACCESS_KEY="..."
export R2_BUCKET="dewemoji-downloads"
export R2_PUBLIC_BASE_URL="https://downloads.dewemoji.com"
```
Canonical update URLs used by app updater:
- `https://dewemoji.com/downloads/version.json`
- `https://dewemoji.com/downloads/dewemoji-latest.apk`
Publish:
```bash
./scripts/apk/publish-r2.sh \
--apk dewemoji-capacitor/dist/apk/dewemoji-v1.1.2-112.apk \
--version-name 1.1.2 \
--version-code 112 \
--min-supported-version-code 100 \
--notes "Bug fixes and update UX improvements" \
--force false
```
Verify:
```bash
./scripts/apk/verify-release.sh --base-url https://dewemoji.com/downloads
```
## 8) Versioning rules
1. `versionCode` must increase on every release.
2. `versionName` is user-facing label; ordering uses `versionCode`.
3. upload APK first, then publish/update `version.json`.
4. never compare update order using `versionName`.
Expected `version.json` fields:
- `versionName` (string)
- `versionCode` (number)
- `apkUrl` (string)
- `notes` (optional)
- `force` (optional bool)
## 9) Rollback
1. keep old versioned APK objects immutable
2. repoint latest artifact (`dewemoji-latest.apk`) to known good version
3. republish matching `version.json`
4. rerun verify script
## 10) High-value troubleshooting
### `cap sync android` failures
```bash
npm --prefix dewemoji-capacitor install
cd dewemoji-capacitor
npx cap sync android
```
### Bubble not appearing
1. confirm overlay permission
2. confirm notification permission (Android 13+)
3. remove OEM battery restrictions
### APK still shows old UI
Rebuild assets, sync, and reinstall debug APK.
## 11) Key files
- `dewemoji-capacitor/src/index.html`
- `dewemoji-capacitor/src/app.css`
- `dewemoji-capacitor/src/app.js`
- `dewemoji-capacitor/android/app/build.gradle`
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/MainActivity.java`
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/OverlayBubbleService.java`
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/plugins/DewemojiOverlayPlugin.java`
- `scripts/apk/build-release.sh`
- `scripts/apk/publish-r2.sh`
- `scripts/apk/verify-release.sh`

View File

@@ -7,8 +7,8 @@ android {
applicationId "com.dewemoji.app" applicationId "com.dewemoji.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 4
versionName "1.0" versionName "1.0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -24,6 +30,17 @@
</activity> </activity>
<service
android:name=".OverlayBubbleService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Floating overlay bubble for quick emoji search and copy" />
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -34,9 +51,4 @@
android:resource="@xml/file_paths"></meta-data> android:resource="@xml/file_paths"></meta-data>
</provider> </provider>
</application> </application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest> </manifest>

View File

@@ -1,30 +1,22 @@
package com.dewemoji.app; package com.dewemoji.app;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageInfo; import android.content.pm.PackageInfo;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment;
import android.util.Log; import android.util.Log;
import android.webkit.WebView;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowCompat; import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat; import androidx.core.view.WindowInsetsControllerCompat;
import com.dewemoji.app.plugins.DewemojiOverlayPlugin;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import org.json.JSONObject; import org.json.JSONObject;
@@ -35,49 +27,94 @@ import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
private static final String TAG = "DewemojiUpdater"; private static final String TAG = "DewemojiUpdater";
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json"; private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
private static final int CONNECT_TIMEOUT_MS = 10_000; private static final String EXTRA_OPENED_FROM_BUBBLE = "dewemoji_opened_from_bubble";
private static final int READ_TIMEOUT_MS = 15_000; private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 15000;
@Nullable private boolean openedFromBubble = false;
private DownloadManager downloadManager;
private long activeDownloadId = -1L;
@Nullable
private String activeExpectedSha = null;
@Nullable
private BroadcastReceiver downloadReceiver = null;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
registerPlugin(DewemojiOverlayPlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
hideSystemBars(); openedFromBubble = wasOpenedFromBubble(getIntent());
downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); applySystemBarMode();
registerDownloadReceiver(); getWindow().getDecorView().post(this::applySystemBarMode);
checkForUpdates(false); checkForUpdates(false);
} }
@Override @Override
public void onDestroy() { protected void onNewIntent(Intent intent) {
super.onDestroy(); super.onNewIntent(intent);
if (downloadReceiver != null) { setIntent(intent);
unregisterReceiver(downloadReceiver); openedFromBubble = wasOpenedFromBubble(intent);
downloadReceiver = null; applySystemBarMode();
}
} }
@Override @Override
public void onWindowFocusChanged(boolean hasFocus) { public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus); super.onWindowFocusChanged(hasFocus);
if (hasFocus) { if (hasFocus) {
hideSystemBars(); applySystemBarMode();
} }
} }
@Override
public void onResume() {
super.onResume();
applySystemBarMode();
getWindow().getDecorView().post(this::applySystemBarMode);
}
@Override
public void onBackPressed() {
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
if (webView == null) {
super.onBackPressed();
return;
}
try {
webView.evaluateJavascript(
"(function(){try{return (window.dewemojiHandleAndroidBack && window.dewemojiHandleAndroidBack()) ? '1' : '0';}catch(e){return '0';}})();",
value -> {
boolean handled = value != null && value.contains("1");
if (handled) {
return;
}
if (webView.canGoBack()) {
webView.goBack();
return;
}
MainActivity.super.onBackPressed();
}
);
} catch (Exception ex) {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}
private boolean wasOpenedFromBubble(Intent intent) {
return intent != null && intent.getBooleanExtra(EXTRA_OPENED_FROM_BUBBLE, false);
}
private void applySystemBarMode() {
// Fallback to non-immersive mode whenever the overlay bubble service is active;
// some launch paths may not preserve the intent extra on all OEMs.
if (openedFromBubble || OverlayBubbleService.isRunning()) {
showSystemBars();
return;
}
hideSystemBars();
}
private void hideSystemBars() { private void hideSystemBars() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false); WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowInsetsControllerCompat controller =
@@ -88,6 +125,13 @@ public class MainActivity extends BridgeActivity {
); );
} }
private void showSystemBars() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
WindowInsetsControllerCompat controller =
new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
controller.show(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars());
}
private void checkForUpdates(boolean manual) { private void checkForUpdates(boolean manual) {
new Thread(() -> { new Thread(() -> {
try { try {
@@ -95,6 +139,7 @@ public class MainActivity extends BridgeActivity {
if (metadata == null) { if (metadata == null) {
return; return;
} }
long installedVersion = getInstalledVersionCode(); long installedVersion = getInstalledVersionCode();
if (metadata.versionCode <= installedVersion) { if (metadata.versionCode <= installedVersion) {
if (manual) { if (manual) {
@@ -104,6 +149,7 @@ public class MainActivity extends BridgeActivity {
} }
return; return;
} }
runOnUiThread(() -> showUpdateDialog(metadata)); runOnUiThread(() -> showUpdateDialog(metadata));
} catch (Exception ex) { } catch (Exception ex) {
Log.w(TAG, "Update check failed", ex); Log.w(TAG, "Update check failed", ex);
@@ -116,7 +162,6 @@ public class MainActivity extends BridgeActivity {
}).start(); }).start();
} }
@Nullable
private UpdateMetadata fetchVersionMetadata() throws Exception { private UpdateMetadata fetchVersionMetadata() throws Exception {
HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection(); HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection();
conn.setRequestMethod("GET"); conn.setRequestMethod("GET");
@@ -159,14 +204,11 @@ public class MainActivity extends BridgeActivity {
if (!metadata.notes.isEmpty()) { if (!metadata.notes.isEmpty()) {
message.append("\n\n").append(metadata.notes); message.append("\n\n").append(metadata.notes);
} }
if (metadata.force) {
message.append("\n\nThis update is required.");
}
AlertDialog.Builder builder = new AlertDialog.Builder(this) AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle("Update Dewemoji") .setTitle("Update Dewemoji")
.setMessage(message.toString()) .setMessage(message.toString())
.setPositiveButton("Update", (dialog, which) -> startApkDownload(metadata)) .setPositiveButton("Download", (dialog, which) -> openApkDownloadPage(metadata.apkUrl))
.setCancelable(!metadata.force); .setCancelable(!metadata.force);
if (!metadata.force) { if (!metadata.force) {
@@ -176,150 +218,23 @@ public class MainActivity extends BridgeActivity {
builder.show(); builder.show();
} }
private void startApkDownload(UpdateMetadata metadata) { private void openApkDownloadPage(String apkUrl) {
if (downloadManager == null) {
Toast.makeText(this, "Download manager unavailable", Toast.LENGTH_SHORT).show();
return;
}
Uri uri = Uri.parse(metadata.apkUrl);
DownloadManager.Request request = new DownloadManager.Request(uri);
request.setTitle("Dewemoji update");
request.setDescription("Downloading version " + metadata.versionName);
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
"dewemoji-latest.apk"
);
request.setMimeType("application/vnd.android.package-archive");
activeExpectedSha = metadata.sha256.toLowerCase(Locale.US);
activeDownloadId = downloadManager.enqueue(request);
Toast.makeText(this, "Downloading update...", Toast.LENGTH_SHORT).show();
}
private void registerDownloadReceiver() {
downloadReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
return;
}
long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
if (downloadId <= 0 || downloadId != activeDownloadId) {
return;
}
verifyAndInstallDownloadedApk(downloadId);
}
};
ContextCompat.registerReceiver(
this,
downloadReceiver,
new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_NOT_EXPORTED
);
}
private void verifyAndInstallDownloadedApk(long downloadId) {
if (downloadManager == null) {
return;
}
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
try (Cursor cursor = downloadManager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
showUpdateError("Download record not found");
return;
}
int statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS);
int uriCol = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI);
int reasonCol = cursor.getColumnIndex(DownloadManager.COLUMN_REASON);
int status = statusCol >= 0 ? cursor.getInt(statusCol) : DownloadManager.STATUS_FAILED;
String localUri = uriCol >= 0 ? cursor.getString(uriCol) : null;
int reason = reasonCol >= 0 ? cursor.getInt(reasonCol) : -1;
if (status != DownloadManager.STATUS_SUCCESSFUL || localUri == null || localUri.isEmpty()) {
showUpdateError("Download failed (" + reason + ")");
return;
}
Uri apkUri = Uri.parse(localUri);
String localSha = computeSha256(apkUri);
if (localSha == null || activeExpectedSha == null || !localSha.equalsIgnoreCase(activeExpectedSha)) {
showUpdateError("Checksum mismatch");
return;
}
installApk(apkUri);
} catch (Exception ex) {
Log.e(TAG, "Failed to verify update APK", ex);
showUpdateError("Update verification failed");
}
}
@Nullable
private String computeSha256(Uri uri) {
try (InputStream input = getContentResolver().openInputStream(uri)) {
if (input == null) {
return null;
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) > 0) {
digest.update(buffer, 0, read);
}
byte[] bytes = digest.digest();
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
out.append(String.format(Locale.US, "%02x", b));
}
return out.toString();
} catch (Exception ex) {
Log.e(TAG, "Failed to compute checksum", ex);
return null;
}
}
private void installApk(Uri downloadUri) {
try { try {
Uri installUri = downloadUri; Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkUrl));
if ("file".equals(downloadUri.getScheme())) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installUri = FileProvider.getUriForFile( startActivity(intent);
this,
getPackageName() + ".fileprovider",
new java.io.File(downloadUri.getPath())
);
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(installUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(installIntent);
} catch (ActivityNotFoundException ex) { } catch (ActivityNotFoundException ex) {
showUpdateError("No installer found"); Toast.makeText(this, "No browser found", Toast.LENGTH_SHORT).show();
} catch (Exception ex) { } catch (Exception ex) {
Log.e(TAG, "Failed to launch APK installer", ex); Log.e(TAG, "Failed to open APK URL", ex);
showUpdateError("Cannot open installer"); Toast.makeText(this, "Cannot open update URL", Toast.LENGTH_SHORT).show();
} }
} }
private void showUpdateError(String message) {
runOnUiThread(() -> new AlertDialog.Builder(this)
.setTitle("Update failed")
.setMessage(message)
.setPositiveButton("OK", (DialogInterface dialog, int which) -> dialog.dismiss())
.show());
}
private static class UpdateMetadata { private static class UpdateMetadata {
final String versionName; final String versionName;
final long versionCode; final long versionCode;
final String apkUrl; final String apkUrl;
final String sha256;
final String notes; final String notes;
final boolean force; final boolean force;
@@ -327,14 +242,12 @@ public class MainActivity extends BridgeActivity {
String versionName, String versionName,
long versionCode, long versionCode,
String apkUrl, String apkUrl,
String sha256,
String notes, String notes,
boolean force boolean force
) { ) {
this.versionName = versionName; this.versionName = versionName;
this.versionCode = versionCode; this.versionCode = versionCode;
this.apkUrl = apkUrl; this.apkUrl = apkUrl;
this.sha256 = sha256;
this.notes = notes; this.notes = notes;
this.force = force; this.force = force;
} }
@@ -343,15 +256,14 @@ public class MainActivity extends BridgeActivity {
String versionName = obj.optString("versionName", ""); String versionName = obj.optString("versionName", "");
long versionCode = obj.optLong("versionCode", 0); long versionCode = obj.optLong("versionCode", 0);
String apkUrl = obj.optString("apkUrl", ""); String apkUrl = obj.optString("apkUrl", "");
String sha256 = obj.optString("sha256", "");
String notes = obj.optString("notes", ""); String notes = obj.optString("notes", "");
boolean force = obj.optBoolean("force", false); boolean force = obj.optBoolean("force", false);
if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty() || sha256.isEmpty()) { if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty()) {
throw new IllegalStateException("Invalid version metadata payload"); throw new IllegalStateException("Invalid version metadata payload");
} }
return new UpdateMetadata(versionName, versionCode, apkUrl, sha256, notes, force); return new UpdateMetadata(versionName, versionCode, apkUrl, notes, force);
} }
} }
} }

View File

@@ -0,0 +1,715 @@
package com.dewemoji.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ServiceInfo;
import android.graphics.PixelFormat;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.IBinder;
import android.provider.Settings;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
public class OverlayBubbleService extends Service {
private static final String TAG = "DewemojiBubbleService";
public static final String ACTION_START = "com.dewemoji.app.action.BUBBLE_START";
public static final String ACTION_STOP = "com.dewemoji.app.action.BUBBLE_STOP";
public static final String ACTION_OPEN = "com.dewemoji.app.action.BUBBLE_OPEN";
public static final String PREFS_NAME = "dewemoji_native_state";
public static final String PREF_BUBBLE_X = "bubblePositionX";
public static final String PREF_BUBBLE_Y = "bubblePositionY";
public static final String NOTIFICATION_CHANNEL_ID = "dewemoji_bubble";
private static final int NOTIFICATION_ID = 41001;
private static volatile boolean running = false;
private WindowManager windowManager;
private ImageView bubbleView;
private View panelBackdropView;
private View panelView;
private WebView panelWebView;
private WindowManager.LayoutParams bubbleParams;
private WindowManager.LayoutParams panelBackdropParams;
private WindowManager.LayoutParams panelParams;
private SharedPreferences prefs;
public static boolean isRunning() {
return running;
}
@Override
public void onCreate() {
super.onCreate();
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
ensureNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final String action = intent != null ? intent.getAction() : null;
if (ACTION_STOP.equals(action)) {
stopSelf();
return START_NOT_STICKY;
}
if (ACTION_OPEN.equals(action)) {
openMainActivity();
if (running) {
startForegroundCompat();
return START_STICKY;
}
}
if (!canDrawOverlays()) {
Toast.makeText(this, "Overlay permission is required for Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
try {
startForegroundCompat();
ensureBubbleView();
// `isAttachedToWindow()` can be false immediately after addView on some
// devices/OEM builds even though the overlay is successfully added.
running = bubbleView != null;
if (!running) {
Toast.makeText(this, "Could not show Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
return START_STICKY;
} catch (Exception ex) {
Log.e(TAG, "Failed to start bubble service", ex);
Toast.makeText(this, "Failed to start Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
}
@Override
public void onDestroy() {
running = false;
removePanelView();
removeBubbleView();
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
private boolean canDrawOverlays() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this);
}
private void ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null) {
return;
}
NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID);
if (channel != null) {
return;
}
channel = new NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Dewemoji Bubble",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Quick access bubble for search and copy");
manager.createNotificationChannel(channel);
}
private Notification buildNotification() {
PendingIntent openPendingIntent = PendingIntent.getActivity(
this,
1001,
buildMainActivityIntent(),
pendingIntentFlags(true)
);
Intent stopIntent = new Intent(this, OverlayBubbleService.class);
stopIntent.setAction(ACTION_STOP);
PendingIntent stopPendingIntent = PendingIntent.getService(
this,
1002,
stopIntent,
pendingIntentFlags(false)
);
return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Dewemoji bubble is running")
.setContentText("Tap bubble to open Dewemoji, copy emoji, then paste in your other app")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(openPendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(android.R.drawable.ic_menu_view, "Open Dewemoji", openPendingIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop Bubble", stopPendingIntent)
.build();
}
private void startForegroundCompat() {
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
);
return;
}
startForeground(NOTIFICATION_ID, notification);
}
private int pendingIntentFlags(boolean updateCurrent) {
int flags = updateCurrent ? PendingIntent.FLAG_UPDATE_CURRENT : 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
return flags;
}
private Intent buildMainActivityIntent() {
Intent launch = new Intent(this, MainActivity.class);
launch.setAction(Intent.ACTION_MAIN);
launch.addCategory(Intent.CATEGORY_LAUNCHER);
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
launch.putExtra("dewemoji_opened_from_bubble", true);
return launch;
}
private void openMainActivity() {
try {
hidePanelView();
startActivity(buildMainActivityIntent());
} catch (Exception ex) {
Toast.makeText(this, "Could not open Dewemoji", Toast.LENGTH_SHORT).show();
}
}
private void ensureBubbleView() {
if (bubbleView != null && bubbleView.isAttachedToWindow()) {
return;
}
if (windowManager == null) {
return;
}
bubbleView = new ImageView(this);
bubbleView.setImageResource(R.drawable.dewemoji_bubble_mark);
bubbleView.setScaleType(ImageView.ScaleType.CENTER_CROP);
int padding = dp(3);
bubbleView.setPadding(padding, padding, padding, padding);
GradientDrawable background = new GradientDrawable();
background.setShape(GradientDrawable.OVAL);
background.setColor(0xFFFFFFFF);
background.setStroke(dp(1), 0x22000000);
bubbleView.setBackground(background);
bubbleView.setElevation(dp(6));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
bubbleView.setClipToOutline(true);
}
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
bubbleParams = new WindowManager.LayoutParams(
dp(64),
dp(64),
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
);
bubbleParams.gravity = Gravity.TOP | Gravity.START;
bubbleParams.x = prefs.getInt(PREF_BUBBLE_X, dp(10));
bubbleParams.y = prefs.getInt(PREF_BUBBLE_Y, dp(180));
clampBubblePosition();
bubbleView.setOnTouchListener(new View.OnTouchListener() {
private int startX;
private int startY;
private float downRawX;
private float downRawY;
private long downTime;
private boolean moved;
@Override
public boolean onTouch(View v, MotionEvent event) {
if (bubbleParams == null || windowManager == null) {
return false;
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
startX = bubbleParams.x;
startY = bubbleParams.y;
downRawX = event.getRawX();
downRawY = event.getRawY();
downTime = System.currentTimeMillis();
moved = false;
return true;
case MotionEvent.ACTION_MOVE:
int dx = Math.round(event.getRawX() - downRawX);
int dy = Math.round(event.getRawY() - downRawY);
if (Math.abs(dx) > dp(3) || Math.abs(dy) > dp(3)) {
moved = true;
}
bubbleParams.x = startX + dx;
bubbleParams.y = startY + dy;
clampBubblePosition();
try {
windowManager.updateViewLayout(bubbleView, bubbleParams);
} catch (Exception ignored) {}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
persistBubblePosition();
long pressDuration = System.currentTimeMillis() - downTime;
if (!moved && pressDuration < 325) {
togglePanelView();
} else if (moved) {
positionPanelNearBubble(false);
try {
if (panelView != null && panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
}
} catch (Exception ignored) {}
}
return true;
default:
return false;
}
}
});
try {
windowManager.addView(bubbleView, bubbleParams);
} catch (Exception ex) {
Log.e(TAG, "windowManager.addView failed", ex);
String reason = ex.getClass().getSimpleName();
String message = ex.getMessage();
if (message != null && !message.trim().isEmpty()) {
String oneLine = message.replace('\n', ' ').trim();
if (oneLine.length() > 90) oneLine = oneLine.substring(0, 90) + "";
reason = reason + ": " + oneLine;
}
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
bubbleView = null;
}
}
private void persistBubblePosition() {
if (bubbleParams == null || prefs == null) {
return;
}
prefs.edit()
.putInt(PREF_BUBBLE_X, bubbleParams.x)
.putInt(PREF_BUBBLE_Y, bubbleParams.y)
.apply();
}
private void togglePanelView() {
if (panelView != null && panelView.isAttachedToWindow()) {
hidePanelView();
return;
}
showPanelView();
}
private void showPanelView() {
if (windowManager == null) return;
ensurePanelBackdropView();
ensurePanelView();
if (panelView == null || panelParams == null) return;
showPanelBackdrop();
positionPanelNearBubble(true);
try {
if (panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
} else {
windowManager.addView(panelView, panelParams);
}
} catch (Exception ex) {
Log.e(TAG, "windowManager.addView(panel) failed", ex);
Toast.makeText(this, "Failed to show panel", Toast.LENGTH_SHORT).show();
}
}
private void hidePanelView() {
if (windowManager != null && panelView != null) {
try {
if (panelView.isAttachedToWindow()) {
windowManager.removeView(panelView);
}
} catch (Exception ignored) {
}
}
hidePanelBackdrop();
}
private void removePanelView() {
hidePanelView();
if (panelWebView != null) {
try {
panelWebView.stopLoading();
panelWebView.loadUrl("about:blank");
panelWebView.destroy();
} catch (Exception ignored) {
}
}
panelWebView = null;
panelView = null;
panelParams = null;
panelBackdropView = null;
panelBackdropParams = null;
}
private void ensurePanelBackdropView() {
if (panelBackdropView != null && panelBackdropParams != null) return;
if (windowManager == null) return;
View backdrop = new View(this);
backdrop.setBackgroundColor(0x26000000);
backdrop.setClickable(true);
backdrop.setFocusable(false);
backdrop.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
hidePanelView();
}
return true;
});
panelBackdropView = backdrop;
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
panelBackdropParams = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
type,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
panelBackdropParams.gravity = Gravity.TOP | Gravity.START;
panelBackdropParams.x = 0;
panelBackdropParams.y = 0;
}
private void showPanelBackdrop() {
if (windowManager == null || panelBackdropView == null || panelBackdropParams == null) return;
try {
if (panelBackdropView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelBackdropView, panelBackdropParams);
} else {
windowManager.addView(panelBackdropView, panelBackdropParams);
}
} catch (Exception ex) {
Log.w(TAG, "Failed to show panel backdrop", ex);
}
}
private void hidePanelBackdrop() {
if (windowManager == null || panelBackdropView == null) return;
try {
if (panelBackdropView.isAttachedToWindow()) {
windowManager.removeView(panelBackdropView);
}
} catch (Exception ignored) {
}
}
private void ensurePanelView() {
if (panelView != null && panelParams != null) return;
if (windowManager == null) return;
FrameLayout card = new FrameLayout(this);
card.setClickable(true);
card.setFocusable(true);
card.setFocusableInTouchMode(true);
card.setElevation(dp(18));
GradientDrawable bg = new GradientDrawable();
bg.setColor(0xFFFFFFFF);
bg.setCornerRadius(dp(18));
bg.setStroke(dp(1), 0x22000000);
card.setBackground(bg);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
card.setClipToOutline(true);
}
WebView webView = new WebView(this);
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
webView.setVerticalScrollBarEnabled(false);
webView.setHorizontalScrollBarEnabled(false);
webView.setBackgroundColor(0xFFFFFFFF);
webView.setWebChromeClient(new WebChromeClient());
webView.setWebViewClient(new WebViewClient());
webView.addJavascriptInterface(new OverlayWebBridge(), "DewemojiOverlayHost");
WebSettings ws = webView.getSettings();
ws.setJavaScriptEnabled(true);
ws.setDomStorageEnabled(true);
ws.setAllowFileAccess(true);
ws.setAllowContentAccess(true);
ws.setMediaPlaybackRequiresUserGesture(false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
ws.setAllowFileAccessFromFileURLs(true);
ws.setAllowUniversalAccessFromFileURLs(true);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
ws.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
}
FrameLayout.LayoutParams webLp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
card.addView(webView, webLp);
panelWebView = webView;
try {
webView.loadUrl(buildOverlayWebViewUrl());
} catch (Exception ex) {
Log.w(TAG, "Failed to load overlay webview URL", ex);
}
panelView = card;
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
panelParams = new WindowManager.LayoutParams(
preferredPanelWidthPx(),
maxPanelHeightPx(),
type,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
);
panelParams.gravity = Gravity.TOP | Gravity.START;
panelParams.x = dp(12);
panelParams.y = dp(120);
panelParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
}
private void positionPanelNearBubble(boolean resetIfOffscreen) {
if (panelParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
panelParams.width = preferredPanelWidthPx();
panelParams.height = Math.min(panelParams.height > 0 ? panelParams.height : maxPanelHeightPx(), maxPanelHeightPx());
panelParams.x = Math.max(safeSideMarginPx(), (screenW - panelParams.width) / 2);
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
clampPanelPosition();
}
private void clampPanelPosition() {
if (panelParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int sideMargin = safeSideMarginPx();
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int panelW = panelParams.width > 0 ? panelParams.width : preferredPanelWidthPx();
int panelH = panelParams.height > 0 ? panelParams.height : maxPanelHeightPx();
int maxX = Math.max(sideMargin, screenW - panelW - sideMargin);
int maxY = Math.max(topSafe, screenH - panelH - bottomSafe);
panelParams.x = Math.max(sideMargin, Math.min(panelParams.x, maxX));
panelParams.y = Math.max(topSafe, Math.min(panelParams.y, maxY));
}
private void clampBubblePosition() {
if (bubbleParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int sideMargin = safeSideMarginPx();
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int bubbleW = bubbleParams.width > 0 ? bubbleParams.width : dp(64);
int bubbleH = bubbleParams.height > 0 ? bubbleParams.height : dp(64);
int maxX = Math.max(sideMargin, screenW - bubbleW - sideMargin);
int maxY = Math.max(topSafe, screenH - bubbleH - bottomSafe);
bubbleParams.x = Math.max(sideMargin, Math.min(bubbleParams.x, maxX));
bubbleParams.y = Math.max(topSafe, Math.min(bubbleParams.y, maxY));
}
private int preferredPanelWidthPx() {
int screenW = getResources().getDisplayMetrics().widthPixels;
int minMargin = dp(10);
int maxWidth = Math.max(dp(220), screenW - (minMargin * 2));
int target = Math.round(screenW * 0.94f);
int minWidth = Math.min(dp(280), maxWidth);
return Math.max(minWidth, Math.min(maxWidth, target));
}
private int maxPanelHeightPx() {
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int available = Math.max(dp(360), screenH - topSafe - bottomSafe);
int maxHeight = Math.max(dp(260), available - dp(8));
int target = Math.round(available * 0.92f);
int minHeight = Math.min(dp(360), maxHeight);
return Math.max(minHeight, Math.min(maxHeight, target));
}
private int minPanelHeightPx() {
int max = maxPanelHeightPx();
return Math.min(max, dp(260));
}
private int safeSideMarginPx() {
return dp(10);
}
private int safeTopInsetPx() {
return readSystemDimenPx("status_bar_height") + dp(8);
}
private int safeBottomInsetPx() {
return readSystemDimenPx("navigation_bar_height") + dp(8);
}
private int readSystemDimenPx(String name) {
try {
int resId = getResources().getIdentifier(name, "dimen", "android");
if (resId > 0) {
return getResources().getDimensionPixelSize(resId);
}
} catch (Exception ignored) {
}
return 0;
}
private String buildOverlayWebViewUrl() {
return "file:///android_asset/public/index.html?mode=overlay";
}
private boolean copyTextToClipboardNative(String text) {
try {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null) return false;
clipboard.setPrimaryClip(ClipData.newPlainText("dewemoji", String.valueOf(text == null ? "" : text)));
return true;
} catch (Exception ex) {
Log.w(TAG, "Native clipboard copy failed", ex);
return false;
}
}
private final class OverlayWebBridge {
@JavascriptInterface
public boolean copyText(String text) {
return copyTextToClipboardNative(text);
}
@JavascriptInterface
public void setContentHeight(int cssPx) {
if (cssPx <= 0) return;
View target = panelView;
if (target == null) return;
target.post(() -> applyPanelContentHeightCss(cssPx));
}
@JavascriptInterface
public void closePanel() {
hidePanelView();
}
@JavascriptInterface
public void openFullApp() {
openMainActivity();
}
}
private void applyPanelContentHeightCss(int cssPx) {
if (panelParams == null || panelView == null || windowManager == null) return;
float density = getResources().getDisplayMetrics().density;
int desiredPx = Math.round(cssPx * density);
desiredPx += dp(2);
desiredPx = Math.max(minPanelHeightPx(), Math.min(desiredPx, maxPanelHeightPx()));
if (Math.abs(desiredPx - panelParams.height) < dp(6)) {
return;
}
panelParams.height = desiredPx;
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
clampPanelPosition();
try {
if (panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
}
} catch (Exception ex) {
Log.w(TAG, "Failed to apply panel content height", ex);
}
}
private void removeBubbleView() {
if (windowManager == null || bubbleView == null) {
bubbleView = null;
return;
}
try {
windowManager.removeView(bubbleView);
} catch (Exception ignored) {
} finally {
bubbleView = null;
}
}
private int dp(int value) {
float density = getResources().getDisplayMetrics().density;
return Math.round(value * density);
}
}

View File

@@ -0,0 +1,173 @@
package com.dewemoji.app.plugins;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.dewemoji.app.OverlayBubbleService;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "DewemojiOverlay")
public class DewemojiOverlayPlugin extends Plugin {
private static final String APP_STATE_KEY = "app_state_json";
@PluginMethod
public void isOverlayPermissionGranted(PluginCall call) {
JSObject out = new JSObject();
out.put("granted", canDrawOverlays());
call.resolve(out);
}
@PluginMethod
public void openOverlayPermissionSettings(PluginCall call) {
try {
Intent intent = new Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getContext().getPackageName())
);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
call.resolve();
} catch (Exception ex) {
call.reject("Failed to open overlay settings", ex);
}
}
@PluginMethod
public void areNotificationsEnabled(PluginCall call) {
JSObject out = new JSObject();
out.put("enabled", notificationsEnabled());
call.resolve(out);
}
@PluginMethod
public void openNotificationSettings(PluginCall call) {
try {
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
call.resolve();
} catch (Exception ex) {
call.reject("Failed to open notification settings", ex);
}
}
@PluginMethod
public void isBubbleRunning(PluginCall call) {
JSObject out = new JSObject();
out.put("running", OverlayBubbleService.isRunning());
call.resolve(out);
}
@PluginMethod
public void startBubble(PluginCall call) {
JSObject out = new JSObject();
if (!canDrawOverlays()) {
out.put("started", false);
out.put("reason", "overlay_permission_required");
call.resolve(out);
return;
}
if (!notificationsEnabled()) {
out.put("started", false);
out.put("reason", "notifications_required");
call.resolve(out);
return;
}
try {
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
intent.setAction(OverlayBubbleService.ACTION_START);
ContextCompat.startForegroundService(getContext(), intent);
out.put("started", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to start Dewemoji bubble", ex);
}
}
@PluginMethod
public void stopBubble(PluginCall call) {
try {
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
intent.setAction(OverlayBubbleService.ACTION_STOP);
getContext().startService(intent);
JSObject out = new JSObject();
out.put("stopped", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to stop Dewemoji bubble", ex);
}
}
@PluginMethod
public void getAppState(PluginCall call) {
JSObject out = new JSObject();
try {
SharedPreferences prefs = nativePrefs();
String raw = prefs.getString(APP_STATE_KEY, "{}");
JSObject state = (raw == null || raw.trim().isEmpty()) ? new JSObject() : new JSObject(raw);
out.put("state", state);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to read app state", ex);
}
}
@PluginMethod
public void setAppState(PluginCall call) {
try {
JSObject incoming = call.getObject("state", new JSObject());
if (incoming == null) {
incoming = new JSObject();
}
nativePrefs().edit().putString(APP_STATE_KEY, incoming.toString()).apply();
JSObject out = new JSObject();
out.put("saved", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to save app state", ex);
}
}
private SharedPreferences nativePrefs() {
return getContext().getSharedPreferences(OverlayBubbleService.PREFS_NAME, Context.MODE_PRIVATE);
}
private boolean canDrawOverlays() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true;
}
return Settings.canDrawOverlays(getContext());
}
private boolean notificationsEnabled() {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
return false;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return ContextCompat.checkSelfPermission(
getContext(),
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED;
}
return true;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,9 +1,5 @@
{ {
"appId": "com.dewemoji.app", "appId": "com.dewemoji.app",
"appName": "Dewemoji", "appName": "Dewemoji",
"webDir": "www", "webDir": "www"
"server": {
"url": "https://dewemoji.com",
"cleartext": false
}
} }

View File

@@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "build": "node ./scripts/build-web.js",
"test": "echo \"No tests configured\""
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const srcDir = path.join(rootDir, 'src');
const outDir = path.join(rootDir, 'www');
function fail(message) {
console.error(`error: ${message}`);
process.exit(1);
}
if (!fs.existsSync(srcDir)) {
fail(`missing source directory: ${srcDir}`);
}
try {
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
fs.cpSync(srcDir, outDir, { recursive: true });
console.log(`Built web assets: ${path.relative(rootDir, outDir)}`);
} catch (error) {
fail(error && error.message ? error.message : String(error));
}

View File

@@ -0,0 +1,967 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
}
/* explicit override by class */
.theme-light {
color-scheme: light;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
.theme-dark {
color-scheme: dark;
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
}
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
/* buttons */
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
.btn:hover { filter: brightness(0.98); }
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
/* disabled buttons (e.g., Load more) */
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
cursor: pointer;
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
position:sticky; bottom:0; background:var(--bg); }
.muted { color:var(--mut); }
.nowrap { white-space: nowrap; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }
.sel {
padding: 8px 10px;
border: 1px solid var(--br);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
appearance: none; /* cleaner look */
background-image:
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.sel:disabled { opacity: .6; }
.badge {
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br);
}
/* Backdrop + Sheet (modal) */
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
z-index: 80; opacity: 0; transition: opacity .18s ease;
}
.backdrop.show { opacity: 1; }
.sheet {
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
}
.sheet.show { transform: translateY(0); opacity: 1; }
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
.sheet-body { padding:12px; display:grid; gap:14px; }
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
.field .hint { margin-top:6px; font-size:12px; }
.row { display:flex; gap:8px; }
/* Radios */
.radios { display:grid; gap:8px; }
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
.radio input[type="radio"] { accent-color: #3b82f6; }
.radio[aria-disabled="true"] { opacity:.55; }
.tag {
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
border:1px solid var(--br); background: var(--dim); color: var(--fg);
}
/* Badge (version) already exists — keep it; this is just a reminder */
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
/* Buttons & inputs already set; ensure icon buttons look good */
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* apply to the glyph container you use */
.emo, .emo * {
font-family:
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
system-ui, sans-serif !important;
font-variant-emoji: emoji;
line-height: 1;
}
.emo img {
width: 1em;
height: 1em;
display: block;
}
#dewemoji-status {
float: right;
}
/* Settings tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--br);
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
background: var(--dim);
border: 1px solid var(--br);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-weight: 600;
}
.tab:not(.active) {
opacity: 0.6;
}
.tab.active {
background: var(--bg);
color: var(--fg);
opacity: 1;
}
.tabpane { display: none; }
.tabpane.active { display: block; }
/* Tone palette theming */
.theme-light, :root.theme-light {
--c-bg: var(--bg, #ffffff);
--c-chip: var(--dim, #f3f4f6);
--c-border: var(--br, #e5e7eb);
}
.theme-dark, :root.theme-dark {
--c-bg: var(--bg, #0f172a);
--c-chip: var(--dim, #111827);
--c-border: var(--br, #374151);
}
/* Accent color for focus/selection rings */
.theme-light, :root.theme-light { --accent: #3b82f6; }
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
/* Tone palette buttons (popover + settings) */
.tone-btn, .tone-chip {
border: 1px solid var(--c-border);
background: var(--c-chip);
}
.tone-btn.selected, .tone-chip.selected {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent) !important;
}
.tone-btn:focus-visible, .tone-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ===== Settings sheet polish ===== */
.sheet { max-width: 540px; }
.sheet-head {
position: sticky; top: 0; background: var(--bg);
z-index: 2; border-bottom: 1px solid var(--br);
}
.sheet-head h3 { margin: 0; }
.sheet-body { padding-top: 10px; }
.field { margin: 12px 0 16px; }
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
/* Compact rows */
.row {
display: grid;
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
gap: 8px;
align-items: center;
}
.row + .row { margin-top: 6px; }
/* Inputs and small buttons */
.inp { padding: 6px 10px; }
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
.btn.ghost { background: transparent; border: 1px solid var(--br); }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
.link:hover { text-decoration: underline; }
/* Tabs = segmented control */
.tabs {
background: var(--dim); padding: 4px; border-radius: 10px;
display: inline-flex; gap: 4px; border: 1px solid var(--br);
margin-bottom: 12px;
}
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacin.g + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
}
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }
/* Make the top controls a proper stacking context above the grid */
.topbar, .filters-row {
position: sticky; /* or relative if not sticky */
z-index: 5;
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
}
/* Or ensure the select dropdown sits above adjacent icons */
select#sub {
position: relative;
z-index: 6;
}
div.card.active {
background-color: var(--active)!important;
}
.tone-row {
grid-column: 1 / -1;
background-color: var(--active);
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(5, minmax(0px, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
height: 56px;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
background: var(--c-bg, #f3f4f6);
display: flex;
align-items: center;
justify-content: center;
font-size: x-large;
}
/* ===== APK companion overrides (extension-like, denser) ===== */
html {
min-height: 100%;
}
body {
height: auto;
min-height: 100%;
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.hdr {
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 6px;
align-content: start;
min-height: calc(100vh - 170px);
background: var(--bg);
}
body.overlay-mode .grid {
min-height: 0;
}
html.overlay-mode,
body.overlay-mode {
min-height: 0;
height: auto;
}
body.overlay-mode {
padding-top: 0;
padding-bottom: 0;
}
body.overlay-mode .hdr,
body.overlay-mode .ft {
position: static;
}
body.overlay-mode #theme {
display: none !important;
}
body.overlay-mode #settings-sheet,
body.overlay-mode #sheet-backdrop {
display: none !important;
}
body.overlay-mode {
font-size: 15px;
}
body.overlay-mode .bar {
gap: 8px;
}
body.overlay-mode .filters {
gap: 8px;
padding: 10px 12px;
}
body.overlay-mode .inp,
body.overlay-mode .sel {
min-height: 48px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body.overlay-mode .btn {
min-height: 46px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body.overlay-mode .btn.icon {
width: 46px;
height: 46px;
min-height: 46px;
padding: 0;
font-size: 18px;
}
body.overlay-mode .btn.w {
min-width: 124px;
}
body.overlay-mode .ft {
padding: 12px;
gap: 10px;
}
body.overlay-mode .sheet {
left: 8px;
right: 8px;
bottom: 8px;
max-width: none;
border-radius: 16px;
box-shadow: 0 16px 44px rgba(0,0,0,.28);
}
body.overlay-mode .sheet-head {
padding: 14px;
}
body.overlay-mode .sheet-head h3 {
font-size: 20px;
}
body.overlay-mode .sheet-body {
padding: 14px;
gap: 16px;
}
body.overlay-mode .field {
margin: 14px 0 18px;
}
body.overlay-mode .field .lbl {
margin-bottom: 8px;
}
body.overlay-mode .field .hint,
body.overlay-mode .diagbox,
body.overlay-mode .muted {
font-size: 13px;
line-height: 1.35;
}
body.overlay-mode .diagbox {
padding: 12px;
border-radius: 10px;
}
body.overlay-mode .row,
body.overlay-mode .row-inline,
body.overlay-mode .row-wrap {
gap: 10px;
}
body.overlay-mode .row-inline {
align-items: stretch;
}
body.overlay-mode .row-wrap .btn,
body.overlay-mode .row-inline .btn {
min-height: 46px;
}
body.overlay-mode #account-connect-form .row-block .inp,
body.overlay-mode #account-connected .row-inline .btn {
min-height: 50px;
}
body.overlay-mode .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body.overlay-mode .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body.overlay-mode .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .tone-pref-group {
gap: 10px;
}
body.overlay-mode .tone-swatch {
width: 42px;
height: 42px;
}
body.overlay-mode .tone-circle {
width: 28px;
height: 28px;
}
body.overlay-mode .bubble-grid {
gap: 10px;
}
body.overlay-mode .bubble-kv {
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .bubble-kv > span:last-child {
font-size: 14px;
}
body.overlay-mode .tone-row {
gap: 8px;
padding: 8px;
}
body.overlay-mode .tone-option {
min-height: 48px;
font-size: 24px;
}
@media (pointer: coarse), (max-width: 900px) {
body:not(.overlay-mode) .inp,
body:not(.overlay-mode) .sel {
min-height: 46px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn {
min-height: 44px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn.icon {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
font-size: 18px;
}
body:not(.overlay-mode) .filters {
gap: 8px;
padding: 10px 10px;
}
body:not(.overlay-mode) .bar {
gap: 8px;
}
body:not(.overlay-mode) .ft {
gap: 10px;
padding: 12px 10px;
}
body:not(.overlay-mode) .sheet {
border-radius: 14px;
}
body:not(.overlay-mode) .sheet-head {
padding: 14px;
}
body:not(.overlay-mode) .sheet-head h3 {
font-size: 20px;
}
body:not(.overlay-mode) .sheet-body {
padding: 14px;
gap: 16px;
}
body:not(.overlay-mode) .field {
margin: 14px 0 18px;
}
body:not(.overlay-mode) .row,
body:not(.overlay-mode) .row-inline,
body:not(.overlay-mode) .row-wrap {
gap: 10px;
}
body:not(.overlay-mode) .row-inline {
align-items: stretch;
}
body:not(.overlay-mode) .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body:not(.overlay-mode) .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body:not(.overlay-mode) .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body:not(.overlay-mode) #account-connect-form .row-block .inp,
body:not(.overlay-mode) #account-connected .row-inline .btn {
min-height: 50px;
}
body:not(.overlay-mode) .bubble-kv {
padding: 10px;
border-radius: 10px;
}
}
.card {
min-height: 72px;
padding: 8px 6px;
gap: 4px;
border-radius: 10px;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card * {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card .emo {
font-size: 24px;
}
.card .nm {
font-size: 10px;
line-height: 1.15;
max-width: 100%;
}
body.emoji-names-hidden .card {
min-height: 58px;
gap: 2px;
padding-top: 7px;
padding-bottom: 7px;
}
body.emoji-names-hidden .card .nm {
display: none;
}
body.emoji-names-hidden .card .emo {
font-size: 28px;
}
.btn.w {
min-width: 110px;
}
.ft {
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
background: var(--bg);
}
.sheet {
bottom: calc(12px + env(safe-area-inset-bottom));
}
.row-inline {
display: flex;
grid-template-columns: none;
align-items: center;
}
.row-wrap {
display: flex;
grid-template-columns: none;
flex-wrap: wrap;
}
.row-block {
display: block;
grid-template-columns: none;
}
.row-block .inp {
width: 100%;
}
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.tone-pref-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tone-swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
cursor: pointer;
}
.tone-swatch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.tone-circle {
display: block;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--br);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.tone-default {
background:
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
}
.tone-1 { background: #f7d7c4; }
.tone-2 { background: #e8bf95; }
.tone-3 { background: #c68642; }
.tone-4 { background: #8d5524; }
.tone-5 { background: #5e3b22; }
.tone-swatch input:checked + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
border-color: #60a5fa;
}
.tone-swatch input:focus-visible + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.bubble-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 10px;
}
.bubble-kv {
display: grid;
gap: 4px;
border: 1px solid var(--br);
background: var(--bg);
border-radius: 8px;
padding: 8px;
min-width: 0;
}
.bubble-kv > span:last-child {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-kv .ok { color: #059669; }
.bubble-kv .warn { color: #d97706; }
.bubble-kv .bad { color: #dc2626; }
.alert {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 10px;
}
.alert strong {
display: block;
margin-bottom: 2px;
}
.alert.error {
border-color: rgba(220, 38, 38, .35);
}
.alert.warn {
border-color: rgba(217, 119, 6, .35);
}
.autoload-sentinel {
width: 100%;
height: 1px;
}
.card.private {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
}
.card.private .nm::after {
content: " · private";
color: #3b82f6;
}
.card.toneable::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #60a5fa;
opacity: 0.9;
}
.tone-row {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 8px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
min-height: 42px;
border-radius: 10px;
border: 1px solid var(--br);
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tone-option.selected {
box-shadow: inset 0 0 0 2px #60a5fa;
border-color: #60a5fa;
}
.tone-option.label {
font-size: 12px;
font-weight: 600;
}
.tone-note {
grid-column: 1 / -1;
font-size: 12px;
color: var(--mut);
padding: 2px 2px 0;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 5px;
padding: 8px;
}
.card {
min-height: 64px;
padding: 6px 4px;
}
.card .emo {
font-size: 22px;
}
.card .nm {
font-size: 9px;
}
body.emoji-names-hidden .card {
min-height: 52px;
padding-top: 6px;
padding-bottom: 6px;
}
body.emoji-names-hidden .card .emo {
font-size: 25px;
}
.bubble-grid {
grid-template-columns: 1fr;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tab {
width: 100%;
text-align: center;
}
}
@media (max-width: 420px) {
.card {
min-height: 54px;
gap: 2px;
}
.card .emo {
font-size: 20px;
}
.card .nm {
font-size: 8px;
}
body.emoji-names-hidden .card .emo {
font-size: 24px;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
padding: 6px;
}
.tone-option {
min-height: 38px;
font-size: 20px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dewemoji</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1220" />
<link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
<link href="app.css" rel="stylesheet" />
</head>
<body class="theme-dark">
<header class="hdr">
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
<div class="bar">
<input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
</div>
</header>
<section class="filters">
<select id="cat" class="sel" aria-label="Category filter">
<option value="">All categories</option>
</select>
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
<option value="">All subcategories</option>
</select>
</section>
<main id="list" class="grid" aria-live="polite"></main>
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
<footer class="ft">
<button id="more" class="btn w">Load more</button>
<span id="count" class="muted nowrap">0 items</span>
<span id="ver" class="badge nowrap">APK</span>
</footer>
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
</div>
<div class="sheet-body">
<div class="tabs" role="tablist" aria-label="Settings tabs">
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
</div>
<section id="tab-general" class="tabpane active" role="tabpanel">
<div class="field">
<label class="lbl">Instant Access</label>
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
</div>
<div class="field">
<label class="lbl">Current Session</label>
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
</div>
<div class="field">
<label class="lbl">Grid display</label>
<label class="radio row-inline" for="show-emoji-names-toggle">
<input id="show-emoji-names-toggle" type="checkbox" />
<span>Show emoji names under each emoji</span>
</label>
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
</div>
<div class="field">
<label class="lbl">Skin tone</label>
<label class="radio row-inline" for="tone-lock-toggle">
<input id="tone-lock-toggle" type="checkbox" />
<span>Use preferred skin tone on tap (tone lock)</span>
</label>
<div class="row row-block" style="margin-top:8px;">
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
<label class="tone-swatch" title="Default (no tone)">
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
<span class="tone-circle tone-default" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Light skin tone">
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
<span class="tone-circle tone-1" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-light skin tone">
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
<span class="tone-circle tone-2" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium skin tone">
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
<span class="tone-circle tone-3" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-dark skin tone">
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
<span class="tone-circle tone-4" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Dark skin tone">
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
<span class="tone-circle tone-5" aria-hidden="true"></span>
</label>
</div>
</div>
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
</div>
<div class="field">
<label class="lbl">Refresh catalog</label>
<div class="row row-inline">
<button id="refresh" class="btn">Refresh</button>
<span id="api-status" class="muted">Loading…</span>
</div>
</div>
</section>
<section id="tab-account" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Account</label>
<div id="account-connect-form">
<div class="row row-block">
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
</div>
<div class="row row-block" style="margin-top:6px;">
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-login" class="btn">Connect</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
</div>
</div>
<div id="account-connected" style="display:none;">
<div class="row row-inline">
<span id="account-greeting" class="muted">Connected.</span>
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-logout" class="btn ghost">Logout</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span class="muted">Private keyword matches appear in search when available.</span>
</div>
</div>
</div>
</section>
<section id="tab-bubble" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Floating bubble (quick search + copy)</label>
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app youre typing in.</p>
</div>
<div class="bubble-grid">
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
</div>
<div class="field">
<div class="row row-wrap">
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
</div>
<div class="row row-wrap" style="margin-top:8px;">
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
</div>
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
</div>
</section>
</div>
</aside>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="twemoji-lite.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
(() => {
const root = window;
if (root?.twemoji?.convert?.toCodePoint) return;
function toCodePoint(unicodeSurrogates, sep = '-') {
const r = [];
let c = 0;
let p = 0;
let i = 0;
const s = String(unicodeSurrogates || '');
while (i < s.length) {
c = s.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
p = 0;
} else if (c >= 0xD800 && c <= 0xDBFF) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join(sep);
}
root.twemoji = root.twemoji || {};
root.twemoji.convert = root.twemoji.convert || {};
root.twemoji.convert.toCodePoint = toCodePoint;
})();

View File

@@ -0,0 +1,967 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
}
/* explicit override by class */
.theme-light {
color-scheme: light;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
.theme-dark {
color-scheme: dark;
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
}
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
/* buttons */
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
.btn:hover { filter: brightness(0.98); }
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
/* disabled buttons (e.g., Load more) */
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
cursor: pointer;
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
position:sticky; bottom:0; background:var(--bg); }
.muted { color:var(--mut); }
.nowrap { white-space: nowrap; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }
.sel {
padding: 8px 10px;
border: 1px solid var(--br);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
appearance: none; /* cleaner look */
background-image:
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.sel:disabled { opacity: .6; }
.badge {
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br);
}
/* Backdrop + Sheet (modal) */
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
z-index: 80; opacity: 0; transition: opacity .18s ease;
}
.backdrop.show { opacity: 1; }
.sheet {
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
}
.sheet.show { transform: translateY(0); opacity: 1; }
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
.sheet-body { padding:12px; display:grid; gap:14px; }
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
.field .hint { margin-top:6px; font-size:12px; }
.row { display:flex; gap:8px; }
/* Radios */
.radios { display:grid; gap:8px; }
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
.radio input[type="radio"] { accent-color: #3b82f6; }
.radio[aria-disabled="true"] { opacity:.55; }
.tag {
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
border:1px solid var(--br); background: var(--dim); color: var(--fg);
}
/* Badge (version) already exists — keep it; this is just a reminder */
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
/* Buttons & inputs already set; ensure icon buttons look good */
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* apply to the glyph container you use */
.emo, .emo * {
font-family:
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
system-ui, sans-serif !important;
font-variant-emoji: emoji;
line-height: 1;
}
.emo img {
width: 1em;
height: 1em;
display: block;
}
#dewemoji-status {
float: right;
}
/* Settings tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--br);
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
background: var(--dim);
border: 1px solid var(--br);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-weight: 600;
}
.tab:not(.active) {
opacity: 0.6;
}
.tab.active {
background: var(--bg);
color: var(--fg);
opacity: 1;
}
.tabpane { display: none; }
.tabpane.active { display: block; }
/* Tone palette theming */
.theme-light, :root.theme-light {
--c-bg: var(--bg, #ffffff);
--c-chip: var(--dim, #f3f4f6);
--c-border: var(--br, #e5e7eb);
}
.theme-dark, :root.theme-dark {
--c-bg: var(--bg, #0f172a);
--c-chip: var(--dim, #111827);
--c-border: var(--br, #374151);
}
/* Accent color for focus/selection rings */
.theme-light, :root.theme-light { --accent: #3b82f6; }
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
/* Tone palette buttons (popover + settings) */
.tone-btn, .tone-chip {
border: 1px solid var(--c-border);
background: var(--c-chip);
}
.tone-btn.selected, .tone-chip.selected {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent) !important;
}
.tone-btn:focus-visible, .tone-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ===== Settings sheet polish ===== */
.sheet { max-width: 540px; }
.sheet-head {
position: sticky; top: 0; background: var(--bg);
z-index: 2; border-bottom: 1px solid var(--br);
}
.sheet-head h3 { margin: 0; }
.sheet-body { padding-top: 10px; }
.field { margin: 12px 0 16px; }
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
/* Compact rows */
.row {
display: grid;
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
gap: 8px;
align-items: center;
}
.row + .row { margin-top: 6px; }
/* Inputs and small buttons */
.inp { padding: 6px 10px; }
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
.btn.ghost { background: transparent; border: 1px solid var(--br); }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
.link:hover { text-decoration: underline; }
/* Tabs = segmented control */
.tabs {
background: var(--dim); padding: 4px; border-radius: 10px;
display: inline-flex; gap: 4px; border: 1px solid var(--br);
margin-bottom: 12px;
}
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacin.g + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
}
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }
/* Make the top controls a proper stacking context above the grid */
.topbar, .filters-row {
position: sticky; /* or relative if not sticky */
z-index: 5;
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
}
/* Or ensure the select dropdown sits above adjacent icons */
select#sub {
position: relative;
z-index: 6;
}
div.card.active {
background-color: var(--active)!important;
}
.tone-row {
grid-column: 1 / -1;
background-color: var(--active);
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(5, minmax(0px, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
height: 56px;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
background: var(--c-bg, #f3f4f6);
display: flex;
align-items: center;
justify-content: center;
font-size: x-large;
}
/* ===== APK companion overrides (extension-like, denser) ===== */
html {
min-height: 100%;
}
body {
height: auto;
min-height: 100%;
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.hdr {
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 6px;
align-content: start;
min-height: calc(100vh - 170px);
background: var(--bg);
}
body.overlay-mode .grid {
min-height: 0;
}
html.overlay-mode,
body.overlay-mode {
min-height: 0;
height: auto;
}
body.overlay-mode {
padding-top: 0;
padding-bottom: 0;
}
body.overlay-mode .hdr,
body.overlay-mode .ft {
position: static;
}
body.overlay-mode #theme {
display: none !important;
}
body.overlay-mode #settings-sheet,
body.overlay-mode #sheet-backdrop {
display: none !important;
}
body.overlay-mode {
font-size: 15px;
}
body.overlay-mode .bar {
gap: 8px;
}
body.overlay-mode .filters {
gap: 8px;
padding: 10px 12px;
}
body.overlay-mode .inp,
body.overlay-mode .sel {
min-height: 48px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body.overlay-mode .btn {
min-height: 46px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body.overlay-mode .btn.icon {
width: 46px;
height: 46px;
min-height: 46px;
padding: 0;
font-size: 18px;
}
body.overlay-mode .btn.w {
min-width: 124px;
}
body.overlay-mode .ft {
padding: 12px;
gap: 10px;
}
body.overlay-mode .sheet {
left: 8px;
right: 8px;
bottom: 8px;
max-width: none;
border-radius: 16px;
box-shadow: 0 16px 44px rgba(0,0,0,.28);
}
body.overlay-mode .sheet-head {
padding: 14px;
}
body.overlay-mode .sheet-head h3 {
font-size: 20px;
}
body.overlay-mode .sheet-body {
padding: 14px;
gap: 16px;
}
body.overlay-mode .field {
margin: 14px 0 18px;
}
body.overlay-mode .field .lbl {
margin-bottom: 8px;
}
body.overlay-mode .field .hint,
body.overlay-mode .diagbox,
body.overlay-mode .muted {
font-size: 13px;
line-height: 1.35;
}
body.overlay-mode .diagbox {
padding: 12px;
border-radius: 10px;
}
body.overlay-mode .row,
body.overlay-mode .row-inline,
body.overlay-mode .row-wrap {
gap: 10px;
}
body.overlay-mode .row-inline {
align-items: stretch;
}
body.overlay-mode .row-wrap .btn,
body.overlay-mode .row-inline .btn {
min-height: 46px;
}
body.overlay-mode #account-connect-form .row-block .inp,
body.overlay-mode #account-connected .row-inline .btn {
min-height: 50px;
}
body.overlay-mode .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body.overlay-mode .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body.overlay-mode .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .tone-pref-group {
gap: 10px;
}
body.overlay-mode .tone-swatch {
width: 42px;
height: 42px;
}
body.overlay-mode .tone-circle {
width: 28px;
height: 28px;
}
body.overlay-mode .bubble-grid {
gap: 10px;
}
body.overlay-mode .bubble-kv {
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .bubble-kv > span:last-child {
font-size: 14px;
}
body.overlay-mode .tone-row {
gap: 8px;
padding: 8px;
}
body.overlay-mode .tone-option {
min-height: 48px;
font-size: 24px;
}
@media (pointer: coarse), (max-width: 900px) {
body:not(.overlay-mode) .inp,
body:not(.overlay-mode) .sel {
min-height: 46px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn {
min-height: 44px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn.icon {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
font-size: 18px;
}
body:not(.overlay-mode) .filters {
gap: 8px;
padding: 10px 10px;
}
body:not(.overlay-mode) .bar {
gap: 8px;
}
body:not(.overlay-mode) .ft {
gap: 10px;
padding: 12px 10px;
}
body:not(.overlay-mode) .sheet {
border-radius: 14px;
}
body:not(.overlay-mode) .sheet-head {
padding: 14px;
}
body:not(.overlay-mode) .sheet-head h3 {
font-size: 20px;
}
body:not(.overlay-mode) .sheet-body {
padding: 14px;
gap: 16px;
}
body:not(.overlay-mode) .field {
margin: 14px 0 18px;
}
body:not(.overlay-mode) .row,
body:not(.overlay-mode) .row-inline,
body:not(.overlay-mode) .row-wrap {
gap: 10px;
}
body:not(.overlay-mode) .row-inline {
align-items: stretch;
}
body:not(.overlay-mode) .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body:not(.overlay-mode) .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body:not(.overlay-mode) .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body:not(.overlay-mode) #account-connect-form .row-block .inp,
body:not(.overlay-mode) #account-connected .row-inline .btn {
min-height: 50px;
}
body:not(.overlay-mode) .bubble-kv {
padding: 10px;
border-radius: 10px;
}
}
.card {
min-height: 72px;
padding: 8px 6px;
gap: 4px;
border-radius: 10px;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card * {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card .emo {
font-size: 24px;
}
.card .nm {
font-size: 10px;
line-height: 1.15;
max-width: 100%;
}
body.emoji-names-hidden .card {
min-height: 58px;
gap: 2px;
padding-top: 7px;
padding-bottom: 7px;
}
body.emoji-names-hidden .card .nm {
display: none;
}
body.emoji-names-hidden .card .emo {
font-size: 28px;
}
.btn.w {
min-width: 110px;
}
.ft {
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
background: var(--bg);
}
.sheet {
bottom: calc(12px + env(safe-area-inset-bottom));
}
.row-inline {
display: flex;
grid-template-columns: none;
align-items: center;
}
.row-wrap {
display: flex;
grid-template-columns: none;
flex-wrap: wrap;
}
.row-block {
display: block;
grid-template-columns: none;
}
.row-block .inp {
width: 100%;
}
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.tone-pref-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tone-swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
cursor: pointer;
}
.tone-swatch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.tone-circle {
display: block;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--br);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.tone-default {
background:
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
}
.tone-1 { background: #f7d7c4; }
.tone-2 { background: #e8bf95; }
.tone-3 { background: #c68642; }
.tone-4 { background: #8d5524; }
.tone-5 { background: #5e3b22; }
.tone-swatch input:checked + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
border-color: #60a5fa;
}
.tone-swatch input:focus-visible + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.bubble-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 10px;
}
.bubble-kv {
display: grid;
gap: 4px;
border: 1px solid var(--br);
background: var(--bg);
border-radius: 8px;
padding: 8px;
min-width: 0;
}
.bubble-kv > span:last-child {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-kv .ok { color: #059669; }
.bubble-kv .warn { color: #d97706; }
.bubble-kv .bad { color: #dc2626; }
.alert {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 10px;
}
.alert strong {
display: block;
margin-bottom: 2px;
}
.alert.error {
border-color: rgba(220, 38, 38, .35);
}
.alert.warn {
border-color: rgba(217, 119, 6, .35);
}
.autoload-sentinel {
width: 100%;
height: 1px;
}
.card.private {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
}
.card.private .nm::after {
content: " · private";
color: #3b82f6;
}
.card.toneable::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #60a5fa;
opacity: 0.9;
}
.tone-row {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 8px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
min-height: 42px;
border-radius: 10px;
border: 1px solid var(--br);
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tone-option.selected {
box-shadow: inset 0 0 0 2px #60a5fa;
border-color: #60a5fa;
}
.tone-option.label {
font-size: 12px;
font-weight: 600;
}
.tone-note {
grid-column: 1 / -1;
font-size: 12px;
color: var(--mut);
padding: 2px 2px 0;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 5px;
padding: 8px;
}
.card {
min-height: 64px;
padding: 6px 4px;
}
.card .emo {
font-size: 22px;
}
.card .nm {
font-size: 9px;
}
body.emoji-names-hidden .card {
min-height: 52px;
padding-top: 6px;
padding-bottom: 6px;
}
body.emoji-names-hidden .card .emo {
font-size: 25px;
}
.bubble-grid {
grid-template-columns: 1fr;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tab {
width: 100%;
text-align: center;
}
}
@media (max-width: 420px) {
.card {
min-height: 54px;
gap: 2px;
}
.card .emo {
font-size: 20px;
}
.card .nm {
font-size: 8px;
}
body.emoji-names-hidden .card .emo {
font-size: 24px;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
padding: 6px;
}
.tone-option {
min-height: 38px;
font-size: 20px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,182 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dewemoji</title> <title>Dewemoji</title>
<style> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #fff; } <meta name="theme-color" content="#0b1220" />
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; } <link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
.card { max-width: 460px; } <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
h1 { margin: 0 0 8px; font-size: 24px; } <link href="app.css" rel="stylesheet" />
p { opacity: 0.85; line-height: 1.5; }
</style>
</head> </head>
<body> <body class="theme-dark">
<div class="wrap"> <header class="hdr">
<div class="card"> <h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
<h1>Dewemoji</h1> <div class="bar">
<p>If this screen appears, the app could not load <code>https://dewemoji.com</code>. Check internet connection and try again.</p> <input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
</div> </div>
</div> </header>
<section class="filters">
<select id="cat" class="sel" aria-label="Category filter">
<option value="">All categories</option>
</select>
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
<option value="">All subcategories</option>
</select>
</section>
<main id="list" class="grid" aria-live="polite"></main>
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
<footer class="ft">
<button id="more" class="btn w">Load more</button>
<span id="count" class="muted nowrap">0 items</span>
<span id="ver" class="badge nowrap">APK</span>
</footer>
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
</div>
<div class="sheet-body">
<div class="tabs" role="tablist" aria-label="Settings tabs">
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
</div>
<section id="tab-general" class="tabpane active" role="tabpanel">
<div class="field">
<label class="lbl">Instant Access</label>
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
</div>
<div class="field">
<label class="lbl">Current Session</label>
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
</div>
<div class="field">
<label class="lbl">Grid display</label>
<label class="radio row-inline" for="show-emoji-names-toggle">
<input id="show-emoji-names-toggle" type="checkbox" />
<span>Show emoji names under each emoji</span>
</label>
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
</div>
<div class="field">
<label class="lbl">Skin tone</label>
<label class="radio row-inline" for="tone-lock-toggle">
<input id="tone-lock-toggle" type="checkbox" />
<span>Use preferred skin tone on tap (tone lock)</span>
</label>
<div class="row row-block" style="margin-top:8px;">
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
<label class="tone-swatch" title="Default (no tone)">
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
<span class="tone-circle tone-default" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Light skin tone">
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
<span class="tone-circle tone-1" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-light skin tone">
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
<span class="tone-circle tone-2" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium skin tone">
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
<span class="tone-circle tone-3" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-dark skin tone">
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
<span class="tone-circle tone-4" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Dark skin tone">
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
<span class="tone-circle tone-5" aria-hidden="true"></span>
</label>
</div>
</div>
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
</div>
<div class="field">
<label class="lbl">Refresh catalog</label>
<div class="row row-inline">
<button id="refresh" class="btn">Refresh</button>
<span id="api-status" class="muted">Loading…</span>
</div>
</div>
</section>
<section id="tab-account" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Account</label>
<div id="account-connect-form">
<div class="row row-block">
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
</div>
<div class="row row-block" style="margin-top:6px;">
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-login" class="btn">Connect</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
</div>
</div>
<div id="account-connected" style="display:none;">
<div class="row row-inline">
<span id="account-greeting" class="muted">Connected.</span>
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-logout" class="btn ghost">Logout</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span class="muted">Private keyword matches appear in search when available.</span>
</div>
</div>
</div>
</section>
<section id="tab-bubble" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Floating bubble (quick search + copy)</label>
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app youre typing in.</p>
</div>
<div class="bubble-grid">
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
</div>
<div class="field">
<div class="row row-wrap">
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
</div>
<div class="row row-wrap" style="margin-top:8px;">
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
</div>
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
</div>
</section>
</div>
</aside>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="twemoji-lite.js"></script>
<script src="app.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,31 @@
(() => {
const root = window;
if (root?.twemoji?.convert?.toCodePoint) return;
function toCodePoint(unicodeSurrogates, sep = '-') {
const r = [];
let c = 0;
let p = 0;
let i = 0;
const s = String(unicodeSurrogates || '');
while (i < s.length) {
c = s.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
p = 0;
} else if (c >= 0xD800 && c <= 0xDBFF) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join(sep);
}
root.twemoji = root.twemoji || {};
root.twemoji.convert = root.twemoji.convert || {};
root.twemoji.convert.toCodePoint = toCodePoint;
})();

View File

@@ -1,641 +1,142 @@
# Dewemoji Product Direction & Strategy Brief # Dewemoji Product Direction (2026)
**Version:** 2.0 ## Product decision snapshot
**Date:** February 8, 2026
**Author:** Dwindi (Project Owner)
--- Dewemoji is a **personal emoji library** product:
## Executive Summary - Free: public emoji discovery (EN/ID dataset)
- Paid (`Personal`): private keyword library + sync + account features
- Main value: "your words -> your emojis"
Dewemoji pivots from a public community-driven emoji dictionary to a **personal emoji library platform** where users build, sync, and own their keyword vocabularies across devices. This eliminates moderation overhead, AI costs, and public abuse risks while monetizing the core unique value: **private, multilingual keyword customization + seamless sync**. Tagline:
**New Tagline:** *"Your words your emojis, anywhere"* - "Your words -> your emojis, anywhere"
--- ## What is in scope now
## 1. Vision & Mission ### Core model
### Vision 1. Public search remains open and fast.
Enable everyone to find and use emojis using *their* language, slang, and personal vocabulary—without language barriers or platform limitations. 2. Private keywords are user-owned and synced.
3. Paid conversion is driven by personalization value, not by limiting discovery.
### Mission ### Tier model
Provide a semantic emoji discovery platform that:
- Offers unlimited free public search (EN/ID + semantic matching)
- Lets users create private, personal keyword libraries (any language, any script)
- Syncs personal libraries across web, extensions, and future apps
- Removes friction from emoji usage in daily communication
### Core Problem Being Solved - Free: unlimited public search, copy/insert, skin tone utilities
"I spent too much time finding an emoji because I think in my language/slang ('bekicot' for 🐌), and most tools only understand English keywords." - Personal: private keywords, keyword sync, API keys, dashboard access
--- Pricing target used in planning:
## 2. Strategic Pivot: Why Private-Only Keywords - Monthly: `$4.99`
- Annual: `$49`
- Lifetime: `$99`
### Previous Model (Abandoned) ## UX direction
- Public community keyword submissions
- Voting/moderation for global searchability
- AI moderation for toxicity
- SEO feeding from user keywords
### Problems with Previous Model ### User states
1. **AI moderation costs** haunting scalability
2. **Moral responsibility** for public bad words feeding internet
3. **Unclear monetization** (who pays for emoji stuff?)
4. **Complexity** managing voting, thresholds, public curation
### New Model (Adopted) 1. Visitor: discover and use immediately (no login wall)
- **Fully private user keyword libraries** 2. Free logged-in user: sees upgrade paths where personalization would help
- No public submissions, no voting, no AI moderation 3. Personal user: quick add on detail pages + full dashboard management
- Users own and control their keywords (stored per account)
- Public search stays free and unlimited (referrer-whitelisted)
### Benefits
**Zero moderation burden** (private = user's responsibility)
**No AI costs** (no content filtering needed)
**Clear monetization** (pay for personal sync, not public features)
**Moral simplicity** (no liability for user's private words)
**Technical simplicity** (CRUD keywords per user_id)
---
## 3. Monetization Model ### Primary flow
### Core Principle 1. User discovers emoji from public search.
**"Free discovery → Paid personalization"** 2. Personal user adds custom keyword from emoji detail page.
3. Keyword becomes immediately searchable for that user.
4. Dashboard handles bulk CRUD/import/export/API key management.
Users get unlimited free public search to discover and love the product, then upgrade to sync their personal vocabulary. ### Upgrade triggers
### Tiers
| Feature | Free | Personal ($4.99/mo) |
|---------|------|---------------------|
| **Public emoji search** | ✅ Unlimited (EN/ID keywords) | ✅ Unlimited |
| **Search result limit** | ✅ No limits | ✅ No limits |
| **Skintone variations** | ✅ Free | ✅ Free |
| **Copy to clipboard** | ✅ Free | ✅ Free |
| **Auto-Insert to page** | ✅ Free (viral hook!) | ✅ Free |
| **Automatic mode** | ✅ Free | ✅ Free |
| **Private keywords** | ❌ Not available | ✅ Unlimited CRUD |
| **Keyword sync** | ❌ Not available | ✅ Cross-device/app |
| **API keys** | ❌ Not available | ✅ Generate/revoke |
| **Dashboard access** | ❌ Not available | ✅ Full management |
### Tier Naming
- **Free** → stays "Free"
- **Pro** → renamed to **"Personal"** (reflects private/personal keywords, not business/pro tools)
### Pricing Options
- **Monthly:** $4.99/month (Stripe subscription)
- **Annual:** $49/year (~$4.08/month, 17% savings)
- **Lifetime:** $99 (one-time, early adopter offer)
### Payment Flexibility
Self-generated API keys mean **any payment provider works**:
- Stripe (primary, global)
- Paddle (backup)
- Midtrans/Xendit (Indonesia localization)
- No dependency on Gumroad license keys
---
## 4. Channel Roles & User Flows
### 4.1 Website (dewemoji.com)
#### Non-Logged Users
**Purpose:** Free discovery & viral entry point
**Features:**
- Search emojis by public keywords (EN/ID)
- Browse by category/subcategory
- Copy emojis to clipboard
- View emoji details (unified, codepoints, shortcodes)
- No account required
**CTA:** "Want your own keywords like 'bekicot'? → Sign Up Free"
#### Logged Users (Free Account)
**Purpose:** Dashboard teaser
**Features:**
- View personal keywords section (muted/disabled with upgrade prompt)
- View API keys section (muted/disabled)
- Access to upgrade flow
- Search still unlimited (public keywords)
**State:** "You have 0 personal keywords. Upgrade to Personal to create unlimited."
#### Logged Users (Personal Tier)
**Purpose:** Full keyword management hub
**Features:**
- **Keyword Management:**
- Table view: emoji | your keywords | language | actions (edit/delete)
- Add new keyword: select emoji → enter keyword(s) → set language → save
- Bulk import/export (JSON)
- **API Key Management:**
- Generate new keys (dew_abc123...)
- Revoke keys
- Copy to clipboard (for extension login)
- View usage stats per key (future)
- **Billing Dashboard:**
- Current plan status
- Payment history table (date, amount, status, invoice)
- Upgrade/downgrade/cancel buttons
- Next billing date
**Navigation:**
```
Dashboard
├─ Search (always visible)
├─ My Keywords
├─ API Keys
└─ Billing
```
---
### 4.2 Chrome Extension
**Purpose:** Viral growth engine + seamless Personal upsell
#### Free Features (Unlimited, No Limits)
- Search public emoji keywords (EN/ID + semantic)
- Skintone variations
- Copy to clipboard
- **Auto-Insert** (click emoji → inserts at cursor position)
- **Automatic mode** (detect input → insert; else copy)
- No daily/hourly limits
- No login required for public search
#### Personal Features (Requires Login)
- Search blends **private + public** keywords
- Personal keywords auto-sync from dashboard
- API key authentication (stored securely in chrome.storage)
#### Auth Flow
1. User clicks "Link Account" in extension settings
2. Login popup (email/password) → POST /v1/user/login
3. Extension receives + stores API key
4. All searches now include Authorization: Bearer header
5. Results blend user's private keywords + public semantic data
#### Upgrade Prompt
- Trigger: User searches term matching no public results
- Message: "Not found in public keywords. Create 'bekicot → 🐌' in your Personal library?"
- CTA: Opens dewemoji.com/upgrade in new tab
---
### 4.3 API (Backend)
**Purpose:** Single source of truth for all channels
#### Public Endpoints (Free, Referrer-Whitelisted)
```
GET /v1/emojis # Search/browse public keywords
GET /v1/emoji/:slug # Emoji detail
GET /v1/categories # Category list
GET /v1/health # Health check
```
**Whitelist Rules:**
- `Origin: https://dewemoji.com`
- `User-Agent: ...chrome-extension://elcikbedkbpkmdhkcmfnkdaacmnpdmha...`
- `Origin: localhost | 127.0.0.1` (dev)
**Rate Limit:** Soft throttle at 5000 requests/hour/IP (invisible to real users, stops bombers)
#### User Endpoints (Authentication Required)
```
POST /v1/user/register # Create account
POST /v1/user/login # Login → returns api_keys
POST /v1/user/logout # Invalidate session
```
#### Personal Tier Endpoints (API Key Required)
```
# Keyword CRUD
GET /v1/keywords # List user's private keywords
POST /v1/keywords # Create/update keyword
DELETE /v1/keywords/:id # Delete keyword
GET /v1/search?private=true # Search (private + public blend)
# API Key Management
GET /v1/user/apikeys # List user's keys
POST /v1/user/apikeys # Generate new key
DELETE /v1/user/apikeys/:key # Revoke key
```
**Authentication:**
- Header: `Authorization: Bearer dew_abc123...`
- Validation: Lookup in `user_api_keys` table → verify user_id has active Personal subscription
---
## 5. Technical Implementation
### 5.1 Database Schema Changes
#### New Tables
```sql
-- Users table
users (
id UUID PRIMARY KEY,
email VARCHAR UNIQUE NOT NULL,
password_hash VARCHAR NOT NULL,
tier ENUM('free', 'personal') DEFAULT 'free',
created_at TIMESTAMP,
updated_at TIMESTAMP
)
-- API keys table
user_api_keys (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
key VARCHAR(64) UNIQUE NOT NULL, -- e.g., dew_abc123...
name VARCHAR(100), -- optional label
created_at TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP NULL
)
-- Private keywords table
user_keywords (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
emoji_slug VARCHAR NOT NULL, -- references existing emoji dataset
keyword VARCHAR(200) NOT NULL,
lang VARCHAR(10) NOT NULL, -- ISO code or 'slang'
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE(user_id, emoji_slug, keyword)
)
-- Subscriptions table (for billing)
subscriptions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
plan VARCHAR(20), -- 'monthly', 'annual', 'lifetime'
status ENUM('active', 'canceled', 'expired'),
started_at TIMESTAMP,
expires_at TIMESTAMP NULL,
stripe_subscription_id VARCHAR NULL
)
```
### 5.2 API Validation Flow
**For Public Endpoints:**
```
1. Check Origin/Referer/User-Agent
2. If whitelisted → allow unlimited
3. Else → require valid API key (Personal tier)
4. Apply soft IP throttle (5000/hour)
```
**For Personal Endpoints:**
```
1. Extract Authorization: Bearer <key>
2. Lookup key in user_api_keys (not revoked)
3. Lookup user_id → verify subscriptions.status = 'active'
4. If valid → proceed
5. Else → 401 Unauthorized
```
### 5.3 Search Logic (Private + Public Blend)
**Endpoint:** `GET /v1/search?q=bekicot&private=true`
**Algorithm:**
```
1. If private=true and valid API key:
a. Query user_keywords WHERE user_id=X AND keyword LIKE '%bekicot%'
b. Query public emojis.json WHERE keywords LIKE '%bekicot%'
c. Merge results (private first, then public)
d. Deduplicate by emoji_slug
2. If private=false or no API key:
a. Query public emojis.json only
3. Return unified response
```
### 5.4 API Key Generation
**Format:** `dew_{random_32_chars}`
**Example:** `dew_k7j3m9q2n8p4r6s1t5v8w0x2y4z7a9c1`
**Generation:**
```php
function generateApiKey() {
return 'dew_' . bin2hex(random_bytes(16));
}
```
**Storage:** Hashed (bcrypt) or plain (with secure storage + HTTPS)
---
## 6. Migration Strategy
### Phase 1: Foundation (Week 1-2)
**Backend:**
- [ ] Create database tables (users, user_api_keys, user_keywords, subscriptions)
- [ ] Build auth endpoints (register, login, logout)
- [ ] Build API key CRUD endpoints
- [ ] Build private keyword CRUD endpoints
- [ ] Implement referrer whitelisting logic
- [ ] Update search endpoint to blend private + public
**Frontend (Website):**
- [ ] Build registration/login UI
- [ ] Build dashboard with tabs (Keywords, API Keys, Billing)
- [ ] Build keyword management table (CRUD interface)
- [ ] Build API key management UI
- [ ] Add upgrade CTAs for free users
**Testing:**
- [ ] Test auth flow
- [ ] Test keyword CRUD
- [ ] Test search blending (private + public)
- [ ] Test referrer whitelisting
### Phase 2: Extension Integration (Week 3)
**Extension Update:**
- [ ] Add "Link Account" button in settings
- [ ] Build login popup (OAuth-style or simple form)
- [ ] Store API key in chrome.storage.sync
- [ ] Update search to send Authorization header when logged in
- [ ] Update UI to show "Signed in as {email}"
- [ ] Add logout button
- [ ] Add upgrade prompt on private keyword miss
**Testing:**
- [ ] Test login flow from extension
- [ ] Test private keyword search from extension
- [ ] Test auto-insert with private keywords
- [ ] Test logout/revoke flow
### Phase 3: Payment Integration (Week 4)
**Stripe Setup:**
- [ ] Create Stripe products (Monthly, Annual, Lifetime)
- [ ] Build checkout flow (dewemoji.com/upgrade)
- [ ] Build webhook handler (subscription created/updated/canceled)
- [ ] Update subscriptions table on payment events
- [ ] Build billing dashboard (payment history, cancel)
**Testing:**
- [ ] Test full upgrade flow (free → Personal)
- [ ] Test subscription renewal
- [ ] Test cancellation
- [ ] Test lifetime purchase
### Phase 4: Deprecation of Gumroad (Optional)
- [ ] Add migration tool for existing Gumroad license holders
- [ ] Offer 1-click conversion: paste license key → create account → auto-upgrade to Personal
- [ ] Send email to existing users with migration instructions
- [ ] Set sunset date for Gumroad validation (e.g., 90 days)
---
## 7. Changes Required to Current Site
### Homepage (dewemoji.com)
**Current:** General emoji search + copy
**Required Changes:**
- [ ] Add hero section: "Find emojis in *your* language. Add personal keywords like 'bekicot' → 🐌"
- [ ] Add CTA: "Search Free" + "Create Personal Library"
- [ ] Add feature comparison table (Free vs Personal)
- [ ] Update footer: Add "Pricing", "Dashboard", "API Docs"
### Search Page
**Current:** Public search only
**Required Changes:**
- [ ] No changes for non-logged users
- [ ] For logged Personal users: blend private keywords in results
- [ ] Add badge on results: "Your keyword" vs "Public keyword"
- [ ] Add "Edit keyword" quick action on personal results
### Emoji Detail Page
**Current:** Shows public keywords, metadata
**Required Changes:**
- [ ] Add "Add to my keywords" button (Personal users only)
- [ ] Quick add modal: "What do you call this? → [input] → Save"
- [ ] Show user's private keywords for this emoji (if any)
- [ ] Remove voting/public submission UI (deprecated)
### New Pages to Build
#### `/register`
- Email + password form
- "Continue with Google" (optional, future)
- Terms acceptance
- Auto-login after registration
#### `/login`
- Email + password
- "Forgot password" link
- Redirect to dashboard after login
#### `/dashboard`
**Tabs:**
1. **My Keywords** (default)
- Table: Emoji | Keywords | Language | Actions
- Add button → modal (search emoji → add keyword)
- Bulk import/export buttons
2. **API Keys**
- List of keys with names + last used
- Generate button → creates new key
- Copy button + revoke button per key
3. **Billing**
- Current plan badge
- Next billing date
- Payment history table
- Upgrade/downgrade/cancel buttons
#### `/upgrade` or `/pricing`
- Feature comparison table (Free vs Personal)
- Pricing cards (Monthly $4.99 | Annual $49 | Lifetime $99)
- Stripe checkout integration
- FAQ section
---
## 8. Marketing & Positioning
### Target Audiences
**Primary:**
1. **Multilingual communicators** (Indonesian, Korean, Japanese, etc.)
- Problem: "Emoji pickers don't understand my language"
- Hook: "Search in bahasa Indonesia: 'bekicot' → 🐌"
2. **Heavy messengers** (WhatsApp, Discord, Twitter power users)
- Problem: "I use emojis 50+ times/day, default pickers suck"
- Hook: "Auto-insert emojis anywhere, instantly"
3. **Content creators & social media managers**
- Problem: "Need specific emojis fast, can't waste time scrolling"
- Hook: "Your custom emoji library, synced everywhere"
**Secondary:**
1. Developers needing semantic emoji API
2. Writers using emojis in documentation
3. Non-Latin language communities (Korean, Japanese, Russian)
### Positioning Statements
**For Indonesian users:**
*"Dewemoji adalah library emoji pribadi yang ngerti bahasa lo—dari 'bekicot' sampe slang gaul, semua bisa jadi shortcut emoji lo."*
**For global users:**
*"Your personal emoji dictionary that speaks your language—sync your custom keywords across all your devices."*
**For Chrome Web Store:**
*"Emoji search that understands you. Add personal keywords like 'snail' or 'bekicot'—works anywhere, syncs everywhere."*
### Growth Channels
1. **Chrome Web Store**
- Current: Already live (https://chromewebstore.google.com/detail/dewemoji...)
- Action: Update description with Personal tier benefits
- Strategy: Drive reviews via in-extension prompt ("Enjoying Dewemoji? Rate us!")
2. **Indonesian tech communities**
- Reddit: r/indonesia, r/indonesian
- Kaskus, Discord servers (DevID, IndieHackers Indonesia)
- Messaging: "Finally, emoji search yang ngerti bahasa kita"
3. **Product Hunt launch**
- Position: "Personal emoji library with multilingual keyword search"
- Hook: "Stop scrolling through emoji pickers—search in your language"
4. **Twitter/X shares**
- User screenshots: "Look, I can search 'bekicot' now!"
- Developer shares: "Semantic emoji API that understands Indonesian slang"
5. **SEO (organic)**
- Target: "emoji search [language]", "emoji picker indonesia", "find emoji by keyword"
- Content: Blog posts on emoji localization, multilingual search
---
## 9. Success Metrics
### Growth Metrics
- **Extension installs/month:** Target 1,000 → 10,000 in 6 months
- **Website MAU:** Target 5,000 → 50,000 in 6 months
- **Free → Personal conversion:** Target 2-5% (industry standard)
### Revenue Metrics
- **MRR (Monthly Recurring Revenue):** Target $500 → $5,000 in 6 months
- **Annual plan adoption:** Target 30% of paid users
- **Lifetime purchases:** Target 10% of paid users
### Usage Metrics
- **Avg searches/user/day:** Track engagement depth
- **Private keywords created/user:** Measure feature adoption
- **API key active usage:** Track cross-device sync adoption
### Quality Metrics
- **Extension rating:** Maintain >4.5 stars
- **User retention (30-day):** Target >40%
- **Churn rate:** Target <10%/month for Personal tier
--- - Search miss prompts
- Locked "Your Keywords" section for free users
## 10. Risks & Mitigation - Extension contextual prompts
### Risk 1: Low conversion (free → Personal) ## Platform responsibilities
**Mitigation:**
- Make free tier extremely generous (unlimited searches, all features except private)
- Add in-context upgrade prompts ("Not found. Create this keyword in Personal?")
- Offer 7-day free trial on Personal tier
### Risk 2: API abuse (free tier bombing) ### Website
**Mitigation:**
- Referrer whitelisting (already planned)
- Soft IP throttle (5000/hour)
- Monitor usage patterns; tighten if needed
### Risk 3: Low awareness ("Who needs emoji search?") - discovery pages
**Mitigation:** - emoji detail
- Target pain-aware users (multilingual, heavy messengers) - pricing/upgrade
- Leverage Chrome Web Store (existing 237M Chrome users search "emoji") - user dashboard (keywords, API keys, billing)
- Create viral hooks (screenshot-worthy Indonesian keyword searches)
### Risk 4: Payment processor issues (Indonesia)
**Mitigation:**
- Start with Stripe (global, easiest)
- Add Midtrans/Xendit for local payment methods (bank transfer, e-wallet)
- Offer annual/lifetime for users without recurring billing access
--- ### Extension
## 11. Next Steps (Immediate Actions)
### Week 1: Backend Foundation - free discovery remains strong
1. Design & implement database schema - account linking for personal sync
2. Build auth system (register, login, JWT/session) - blend private + public results when authenticated
3. Build API key generation/validation
4. Build private keyword CRUD endpoints ### API
5. Update search endpoint to blend private + public
- stable public contract for search/category/detail
### Week 2: Frontend Dashboard - authenticated personal keyword endpoints
1. Build registration/login pages - clear throttling and abuse controls
2. Build dashboard layout (tabs: Keywords, API Keys, Billing)
3. Build keyword management table + CRUD UI ## Architecture priorities
4. Build API key management UI
5. Add upgrade CTAs throughout free tier 1. Move from license-centric model toward account + subscription + API keys.
2. Keep legacy compatibility while migration is active.
### Week 3: Extension Integration 3. Preserve cache-first behavior (`app/data/emojis.json`) for reliable performance.
1. Add "Link Account" in extension settings 4. Keep operational observability for billing/webhooks/usage.
2. Build login popup for extension
3. Store API key securely (chrome.storage) ## Implementation phases
4. Update search to send auth header when logged in
5. Add upgrade prompt on private keyword miss ### Phase A - Foundation
### Week 4: Payment & Launch - user auth foundations
1. Set up Stripe products (Monthly, Annual, Lifetime) - private keyword model
2. Build checkout flow + webhook handler - API key lifecycle
3. Build billing dashboard - public endpoint guard/throttle hardening
4. Test full user journey (register → add keywords → login in extension → search)
5. Soft launch to existing users + Indonesian tech communities ### Phase B - Dashboard
--- - my keywords CRUD
- API key management
## 12. Appendix: Key Decisions Made - billing state view
- role-aware dashboard shell
| Decision | Rationale |
|----------|-----------| ### Phase C - Extension sync
| **Private-only keywords (no public)** | Eliminates moderation costs, AI costs, legal risks, complexity |
| **Free unlimited public search** | Drives viral growth; users fall in love before paywall | - link account
| **Auto-Insert free (not paid)** | Core viral hook; competitors lack this, users share screenshots | - send auth key/header
| **Tier renamed: Pro → Personal** | "Personal" clarifies it's private keywords, not business tools | - show private result badges and edit actions
| **Self-generated API keys** | Flexibility with any payment provider (Stripe, Paddle, local) |
| **Referrer whitelisting for free** | Protects API from abuse without limiting real users | ### Phase D - Billing completion
| **No search count limits** | Emoji tools die with limits; unlimited = frictionless growth |
| **Payment: $4.99/mo or $49/yr** | Coffee price for individuals; low friction, high volume model | - provider webhooks with idempotency
- accurate subscription status transitions
--- - admin controls for pricing and subscription operations
## 13. Questions for Future Consideration ## Community/contribution decision
1. **Team libraries:** Should Personal tier allow shared keyword libraries (e.g., family/team)? Current active direction is **private-first personalization**.
2. **Mobile apps:** Native iOS/Android apps or continue web + extension focus?
3. **API marketplace:** Offer public API access for developers (separate paid tier)? - Public community contribution/voting is not in the immediate build scope.
4. **Localization:** Add more default language packs (Korean, Japanese, Spanish)? - If reintroduced later, it should be optional, moderated, and separated from private keyword ownership.
5. **AI assistance:** "Suggest keywords for this emoji based on my usage patterns"?
## Admin priorities
---
1. subscription and payment visibility
**End of Brief** 2. webhook replay and diagnostics
3. pricing controls + change logging
This document serves as the north star for Dewemoji's development through 2026. All implementation decisions should align with the principle: **"Free discovery → Paid personalization"** and the vision of enabling personal, multilingual emoji expression for everyone. 4. safety controls and audit logs
## Success signals
1. private keyword adoption per Personal user
2. search success lift from private keywords
3. free -> Personal conversion rate
4. extension retention and sync reliability
## Risks to monitor
1. abuse on public endpoints -> enforce edge throttling + allowlists
2. billing webhook drift -> queue + idempotency + replay tooling
3. migration confusion between legacy licenses and Personal model -> explicit migration messaging
## Out of scope for now
- broad public community moderation/voting systems
- heavy AI moderation pipelines for public contributions
- major replatform that breaks current API contracts

View File

@@ -1,873 +0,0 @@
# Dewemoji User Flow & UX Brief
**Version:** 1.0
**Date:** February 8, 2026
**Purpose:** Define seamless user experience across visitor → logged → paid states
**Related Doc:** dewemoji-direction-2026.md
---
## Executive Summary
Dewemoji implements a **hybrid quick-add flow** where Personal users can add keywords instantly from emoji detail pages (80% of use cases) while maintaining full CRUD power in the dashboard (20% management). This creates a frictionless progression: discovery → personalization → habit, without disrupting the free user experience.
**Core UX Principle:** *"Add keywords where you discover emojis, manage them where you organize."*
---
## 1. User State Definitions
### 1.1 Visitor (Non-Logged)
**Who:** Anyone landing on dewemoji.com or using Chrome extension (no account)
**Intent:** Quick emoji search and usage
**Experience:** Zero friction, no login walls
### 1.2 Free User (Logged, No Subscription)
**Who:** Registered account, free tier
**Intent:** Exploring Personal features, considering upgrade
**Experience:** Full free features + gentle upgrade hints
### 1.3 Personal User (Logged, Paid)
**Who:** Active Personal subscription ($4.99/mo, $49/yr, or $99 lifetime)
**Intent:** Building and syncing personal keyword library
**Experience:** Full personalization power across all touchpoints
---
## 2. Primary User Flows
### 2.1 Discovery Flow (All Users)
```
User lands on dewemoji.com
Search bar: "bekicot"
Results page: 🐌 snail (public keywords: "snail, escargot")
Click emoji → Detail page
[State-dependent experience - see Section 3]
```
**Key Insight:** All users start here. Free discovery hooks them; Personal lets them own it.
---
### 2.2 Personalization Flow (Personal Users)
#### Primary: Quick Add from Detail Page (80% Usage)
```
Personal user searches "snail"
Detail page shows:
┌─────────────────────────────────────┐
│ 🐌 Snail │
│ │
│ Public Keywords: snail, escargot │
│ │
│ Your Keywords: (none yet) │
│ [+ Add Your Keyword] │
└─────────────────────────────────────┘
User clicks [+ Add Your Keyword]
Inline modal appears:
┌─────────────────────────────────────┐
│ What do you call this emoji? │
│ ┌─────────────────────────────────┐│
│ │ bekicot, siput ││
│ └─────────────────────────────────┘│
│ Language: [Indonesian ▾] │
│ [Cancel] [Save Keyword] │
└─────────────────────────────────────┘
Saves instantly → Toast: "Added 'bekicot' to your library ✓"
Detail page now shows:
┌─────────────────────────────────────┐
│ 🐌 Snail │
│ │
│ Public Keywords: snail, escargot │
│ │
│ Your Keywords: │
│ • bekicot (ID) [edit] [×] │
│ • siput (ID) [edit] [×] │
│ [+ Add Another] │
└─────────────────────────────────────┘
Next search: "bekicot" → 🐌 appears in results!
```
**Why This Flow:**
- **Contextual:** User is *already looking at* the emoji they want to name
- **Fast:** 2 clicks (add button → save), ~5 seconds total
- **Immediate gratification:** See change instantly in searches
- **Low friction:** No navigation away from discovery flow
---
#### Secondary: Dashboard Management (20% Usage)
**When Used:**
- Bulk editing/organizing keywords
- Reviewing all keywords at once
- Exporting/importing keyword libraries
- Fixing typos across multiple entries
```
User navigates to Dashboard → My Keywords tab
Table view:
┌───────────────────────────────────────────────────────────┐
│ Emoji | Your Keywords | Lang | Actions │
├───────────────────────────────────────────────────────────┤
│ 🐌 | bekicot, siput | ID | [Edit] [Delete] │
│ 😊 | senyum, happy | ID | [Edit] [Delete] │
│ 🔥 | api, fire, lit | ID | [Edit] [Delete] │
└───────────────────────────────────────────────────────────┘
[+ Add Keyword] [Import JSON] [Export JSON] [Search/Filter]
User clicks [+ Add Keyword]
Modal: Search emoji picker → Select 🎉 → Add keywords
Saves to table → Auto-sync to extension
```
**Why Dashboard Exists:**
- Power users want bulk operations
- Easy to review/audit entire library
- Import/export for backup
- Edit mistakes without finding emoji again
---
### 2.3 Upgrade Flow (Free → Personal)
#### Trigger Points
**1. On Detail Page (Soft Prompt)**
```
Free user on emoji detail page sees:
┌─────────────────────────────────────┐
│ 🐌 Snail │
│ │
│ Public Keywords: snail, escargot │
│ │
│ 💎 Want to add your own keywords? │
│ like 'bekicot' or 'siput'? │
│ [Upgrade to Personal →] │
└─────────────────────────────────────┘
```
**2. On Search Miss (Strong Prompt)**
```
User searches "bekicot" → No results
Results page shows:
┌─────────────────────────────────────┐
│ No results for "bekicot" │
│ │
│ 💡 With Personal, you can create │
│ "bekicot → 🐌" and sync it │
│ everywhere. │
│ [Try Personal Free for 7 Days] │
└─────────────────────────────────────┘
```
**3. From Extension (Contextual)**
```
Extension user searches "bekicot" → Not found
Extension shows inline banner:
"Add 'bekicot' to your library with Personal"
[Upgrade] button → Opens dewemoji.com/upgrade
```
**4. From Dashboard (Clear Path)**
```
Free user clicks "My Keywords" tab
Shows empty state:
┌─────────────────────────────────────┐
│ 📚 Your Personal Keyword Library │
│ │
│ You have 0 keywords. │
│ │
│ Upgrade to Personal to: │
│ ✓ Add unlimited keywords │
│ ✓ Search in your language │
│ ✓ Sync across all devices │
│ │
│ [Start 7-Day Free Trial] │
└─────────────────────────────────────┘
```
---
## 3. State-Dependent UI Elements
### 3.1 Emoji Detail Page
#### Visitor (Non-Logged)
```html
<!-- Public info only -->
<div class="emoji-detail">
<h1>🐌 Snail</h1>
<section class="public-keywords">
<h3>Keywords</h3>
<ul>
<li>snail</li>
<li>escargot</li>
<li>slow</li>
</ul>
</section>
<div class="cta-banner">
<p>💡 Want to search in your language?</p>
<button>Sign Up Free</button>
</div>
</div>
```
#### Free User (Logged)
```html
<!-- Public info + muted personal section -->
<div class="emoji-detail">
<h1>🐌 Snail</h1>
<section class="public-keywords">
<h3>Keywords</h3>
<ul><li>snail</li><li>escargot</li></ul>
</section>
<section class="user-keywords muted">
<h3>Your Keywords</h3>
<div class="upgrade-overlay">
<p>💎 Add personal keywords like 'bekicot'</p>
<button class="upgrade-btn">Upgrade to Personal</button>
</div>
</section>
</div>
```
#### Personal User (Logged + Paid)
```html
<!-- Full personalization active -->
<div class="emoji-detail">
<h1>🐌 Snail</h1>
<section class="public-keywords">
<h3>Public Keywords</h3>
<ul><li>snail</li><li>escargot</li><li>slow</li></ul>
</section>
<section class="user-keywords active">
<h3>Your Keywords</h3>
<ul class="keyword-list">
<li>
<span class="keyword">bekicot</span>
<span class="lang-tag">ID</span>
<button class="edit-btn">edit</button>
<button class="delete-btn">×</button>
</li>
<li>
<span class="keyword">siput</span>
<span class="lang-tag">ID</span>
<button class="edit-btn">edit</button>
<button class="delete-btn">×</button>
</li>
</ul>
<button class="add-keyword-btn">+ Add Keyword</button>
</section>
</div>
<!-- Add Keyword Modal (hidden until clicked) -->
<dialog id="add-keyword-modal">
<h3>What do you call this emoji?</h3>
<input type="text" placeholder="Enter keywords (comma-separated)" />
<select name="language">
<option value="en">English</option>
<option value="id">Indonesian</option>
<option value="ko">Korean</option>
<option value="ja">Japanese</option>
</select>
<div class="modal-actions">
<button class="cancel">Cancel</button>
<button class="save primary">Save Keyword</button>
</div>
</dialog>
```
---
### 3.2 Search Results Page
#### All Users (Public Results)
```html
<div class="search-results">
<div class="emoji-card">
<span class="emoji">🐌</span>
<span class="name">Snail</span>
<span class="matched-keyword">snail</span>
</div>
</div>
```
#### Personal User (Blended Results)
```html
<div class="search-results">
<!-- Private keyword match (appears first) -->
<div class="emoji-card user-match">
<span class="emoji">🐌</span>
<span class="name">Snail</span>
<span class="matched-keyword">
bekicot
<span class="badge personal">Your keyword</span>
</span>
<button class="quick-edit">Edit</button>
</div>
<!-- Public keyword matches (below) -->
<div class="emoji-card">
<span class="emoji">🐌</span>
<span class="name">Snail</span>
<span class="matched-keyword">snail</span>
</div>
</div>
```
---
### 3.3 Dashboard (Personal Users Only)
```html
<div class="dashboard">
<nav class="tabs">
<button class="active">My Keywords</button>
<button>API Keys</button>
<button>Billing</button>
</nav>
<!-- My Keywords Tab -->
<div class="tab-content" id="keywords">
<div class="toolbar">
<button class="primary">+ Add Keyword</button>
<button>Import JSON</button>
<button>Export JSON</button>
<input type="search" placeholder="Search your keywords..." />
</div>
<table class="keyword-table">
<thead>
<tr>
<th>Emoji</th>
<th>Your Keywords</th>
<th>Language</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>🐌</td>
<td>bekicot, siput</td>
<td><span class="lang-tag">ID</span></td>
<td>
<button class="edit">Edit</button>
<button class="delete">Delete</button>
</td>
</tr>
<!-- More rows... -->
</tbody>
</table>
</div>
</div>
```
---
## 4. API Endpoints for UX Flows
### 4.1 Detail Page Quick Add
**Fetch user's keywords for specific emoji:**
```
GET /v1/emoji/:slug?include_user_keywords=true
Authorization: Bearer dew_abc123...
Response:
{
"emoji": {...},
"public_keywords": ["snail", "escargot"],
"user_keywords": [
{"id": "uk_1", "keyword": "bekicot", "lang": "id"},
{"id": "uk_2", "keyword": "siput", "lang": "id"}
]
}
```
**Add keyword from detail page:**
```
POST /v1/keywords/quick
Authorization: Bearer dew_abc123...
Body:
{
"emoji_slug": "snail",
"keywords": ["bekicot", "siput"],
"lang": "id"
}
Response:
{
"success": true,
"added": ["bekicot", "siput"],
"emoji_slug": "snail"
}
```
**Edit single keyword:**
```
PATCH /v1/keywords/:id
Authorization: Bearer dew_abc123...
Body:
{
"keyword": "bekicot kecil"
}
```
**Delete keyword:**
```
DELETE /v1/keywords/:id
Authorization: Bearer dew_abc123...
```
---
### 4.2 Dashboard Bulk Operations
**List all user keywords:**
```
GET /v1/keywords?page=1&limit=50
Authorization: Bearer dew_abc123...
Response:
{
"keywords": [
{
"id": "uk_1",
"emoji_slug": "snail",
"emoji": "🐌",
"keyword": "bekicot",
"lang": "id",
"created_at": "2026-02-08T10:30:00Z"
}
// ... more
],
"total": 47,
"page": 1
}
```
**Bulk import:**
```
POST /v1/keywords/import
Authorization: Bearer dew_abc123...
Body:
{
"keywords": [
{"emoji_slug": "snail", "keywords": ["bekicot"], "lang": "id"},
{"emoji_slug": "fire", "keywords": ["api"], "lang": "id"}
]
}
Response:
{
"imported": 2,
"skipped": 0,
"errors": []
}
```
**Export:**
```
GET /v1/keywords/export?format=json
Authorization: Bearer dew_abc123...
Response: (Download JSON file)
```
---
## 5. Frontend Implementation Specs
### 5.1 Emoji Detail Page Changes
**File:** `/pages/emoji/[slug].js` (or equivalent)
**Required Elements:**
1. **User keywords section** (Personal only)
- Fetch on page load: `GET /v1/emoji/:slug?include_user_keywords=true`
- Show list of user's keywords with edit/delete buttons
- Show "Add Keyword" button
2. **Add keyword modal**
- Input: comma-separated keywords
- Dropdown: language selector (EN, ID, KO, JA, etc.)
- Save button → POST `/v1/keywords/quick`
- Success → update UI without page reload
3. **Inline edit**
- Click "edit" → input becomes editable
- Save → PATCH `/v1/keywords/:id`
- Cancel → revert
4. **Inline delete**
- Click "×" → confirm modal
- Yes → DELETE `/v1/keywords/:id` → remove from UI
**State Management:**
```javascript
const [userKeywords, setUserKeywords] = useState([]);
const [isPersonal, setIsPersonal] = useState(false);
// On mount
useEffect(() => {
if (user?.tier === 'personal') {
fetchUserKeywords(emojiSlug);
}
}, [emojiSlug, user]);
// Add keyword
const handleAddKeyword = async (keywords, lang) => {
const result = await api.post('/keywords/quick', {
emoji_slug: emojiSlug,
keywords: keywords.split(',').map(k => k.trim()),
lang
});
if (result.success) {
fetchUserKeywords(emojiSlug); // Refresh list
toast.success('Keywords added!');
}
};
```
---
### 5.2 Dashboard Implementation
**File:** `/pages/dashboard.js`
**Tabs:**
1. My Keywords (default)
2. API Keys
3. Billing
**My Keywords Tab Components:**
```javascript
// Main table component
<KeywordTable
keywords={keywords}
onEdit={handleEdit}
onDelete={handleDelete}
onSearch={handleSearch}
/>
// Toolbar actions
<Toolbar>
<AddKeywordButton onClick={openModal} />
<ImportButton />
<ExportButton />
<SearchInput onChange={handleSearch} />
</Toolbar>
// Add keyword modal (with emoji picker)
<AddKeywordModal
onSave={handleAddKeyword}
onCancel={closeModal}
/>
```
**Features:**
- Pagination (50 per page)
- Search/filter by keyword or emoji
- Bulk select + delete
- Sort by: date added, emoji name, language
---
### 5.3 Upgrade Flow Pages
**File:** `/pages/upgrade.js` or `/pages/pricing.js`
**Components:**
1. **Feature comparison table**
```html
<table class="pricing-comparison">
<tr>
<th>Feature</th>
<th>Free</th>
<th>Personal</th>
</tr>
<tr>
<td>Public emoji search</td>
<td>✅ Unlimited</td>
<td>✅ Unlimited</td>
</tr>
<tr>
<td>Private keywords</td>
<td>❌</td>
<td>✅ Unlimited</td>
</tr>
<!-- ... -->
</table>
```
2. **Pricing cards**
```html
<div class="pricing-cards">
<div class="card">
<h3>Monthly</h3>
<p class="price">$4.99/mo</p>
<button>Subscribe</button>
</div>
<div class="card recommended">
<span class="badge">Best Value</span>
<h3>Annual</h3>
<p class="price">$49/year</p>
<p class="savings">Save 17%</p>
<button>Subscribe</button>
</div>
<div class="card">
<h3>Lifetime</h3>
<p class="price">$99 once</p>
<button>Buy Now</button>
</div>
</div>
```
3. **Stripe checkout integration**
```javascript
const handleCheckout = async (plan) => {
const session = await api.post('/stripe/checkout', {
plan, // 'monthly' | 'annual' | 'lifetime'
success_url: `${baseUrl}/dashboard?upgraded=true`,
cancel_url: `${baseUrl}/upgrade`
});
window.location.href = session.url;
};
```
---
## 6. UX Copy & Messaging
### 6.1 Upgrade Prompts
**Soft (Detail Page):**
> 💡 Want to search in your language? Add personal keywords like 'bekicot' for 🐌
> [Upgrade to Personal →]
**Strong (Search Miss):**
> No results for "bekicot"
>
> 💎 **Create your own keywords with Personal**
> Add "bekicot → 🐌" and sync it across all devices
> [Try Free for 7 Days]
**Empty State (Dashboard):**
> 📚 **Your Personal Keyword Library**
>
> You have 0 keywords.
>
> Upgrade to Personal to:
> ✓ Add unlimited keywords
> ✓ Search in your language
> ✓ Sync across all devices
>
> [Start 7-Day Free Trial]
---
### 6.2 Success Messages
**After adding keyword:**
> ✓ Added "bekicot" to your library
**After editing:**
> ✓ Keyword updated
**After deleting:**
> ✓ Keyword removed
**After upgrade:**
> 🎉 Welcome to Personal! Start adding your keywords.
---
### 6.3 Error Messages
**Duplicate keyword:**
> ⚠️ You already have "bekicot" for this emoji
**Network error:**
> ❌ Couldn't save. Check your connection and try again.
**Unauthorized:**
> 🔒 Please log in to add personal keywords
---
## 7. Mobile Considerations
### 7.1 Detail Page on Mobile
- Add keyword button: **sticky bottom bar** (always visible)
- Modal: **full-screen** on mobile (easier typing)
- Keyword list: **swipeable cards** (swipe left to delete)
### 7.2 Dashboard on Mobile
- Tabs: **horizontal scroll** or bottom nav
- Table: **card view** (stack columns vertically)
- Actions: **swipe gestures** (edit/delete)
---
## 8. Performance & Optimization
### 8.1 Caching Strategy
**Client-side:**
- Cache user keywords in localStorage/sessionStorage
- Only refetch when:
- User adds/edits/deletes
- Page reload after 5 minutes
**API-side:**
- ETag support (already implemented)
- Cache user keywords per user_id (Redis)
- Invalidate on mutation
### 8.2 Loading States
**Detail page:**
```
Loading user keywords...
└─ Skeleton: [▯▯▯▯] [▯▯▯] [▯▯▯▯▯]
```
**Dashboard:**
```
Loading your library...
└─ Table skeleton (5 rows)
```
---
## 9. Analytics & Tracking
### Key Events to Track
**Discovery:**
- `emoji_viewed` (slug, user_tier)
- `public_search` (query, has_results)
- `search_miss` (query) → upgrade opportunity
**Personalization:**
- `keyword_added` (method: 'quick_add' | 'dashboard')
- `keyword_edited`
- `keyword_deleted`
- `keywords_exported`
**Conversion:**
- `upgrade_prompt_shown` (location: 'detail' | 'search' | 'dashboard')
- `upgrade_clicked` (location)
- `trial_started`
- `subscription_created` (plan: 'monthly' | 'annual' | 'lifetime')
**Usage:**
- `private_keyword_searched` (query, has_results)
- `extension_synced` (keyword_count)
---
## 10. Testing Checklist
### 10.1 User State Tests
- [ ] Visitor can search and view emoji details (no login)
- [ ] Visitor sees upgrade CTA (non-intrusive)
- [ ] Free user sees muted keyword section
- [ ] Free user can't add keywords without upgrade
- [ ] Personal user sees active keyword section
- [ ] Personal user can add keywords from detail page
- [ ] Personal user can edit/delete keywords inline
### 10.2 Flow Tests
- [ ] Quick add: add keyword → appears in list immediately
- [ ] Quick add: new keyword searchable instantly
- [ ] Dashboard: bulk add works correctly
- [ ] Dashboard: export downloads valid JSON
- [ ] Dashboard: import restores keywords
- [ ] Search: private keywords appear first in results
- [ ] Search: private keywords have "Your keyword" badge
### 10.3 Edge Cases
- [ ] Add duplicate keyword → shows error
- [ ] Delete last keyword for emoji → removes emoji from search
- [ ] Offline: queue mutations, sync when online
- [ ] Rate limit: show friendly error
- [ ] API error: show retry option
---
## 11. Implementation Priority
### Phase 1: Detail Page Quick Add (Week 1)
1. Add user keywords section to detail page
2. Build add keyword modal
3. Implement POST /keywords/quick endpoint
4. Add inline edit/delete
### Phase 2: Search Blending (Week 1)
1. Update search to blend private + public
2. Add "Your keyword" badge to results
3. Show private matches first
### Phase 3: Dashboard (Week 2)
1. Build My Keywords tab with table
2. Add bulk actions (import/export)
3. Implement search/filter
### Phase 4: Upgrade Flow (Week 2)
1. Build upgrade prompts (detail, search miss, dashboard)
2. Create pricing page
3. Integrate Stripe checkout
---
## 12. Open Questions & Future Enhancements
1. **Keyword suggestions:** Should we suggest keywords based on emoji name/category?
2. **Keyboard shortcuts:** Quick add keyword with hotkey (Ctrl+K)?
3. **Recently added:** Show "Recently added keywords" widget on dashboard?
4. **Keyword collections:** Group keywords by category/theme?
5. **Sharing:** Allow users to share their keyword libraries with friends?
---
**End of UX Brief**
This document defines the seamless user experience for Dewemoji's core value proposition: instant, contextual personalization. Implementation should prioritize the detail page quick-add flow (80% of usage) while building dashboard for power users (20%).

View File

@@ -1,117 +0,0 @@
# .env Migration Checklist
Use this to map legacy `dewemoji-live-backend/public_html/config/env.php` into NativePHP app `app/.env`.
## 1) Local development (safe default)
```env
APP_ENV=local
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
DB_CONNECTION=sqlite
DEWEMOJI_BILLING_MODE=sandbox
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_PRO_KEYS=
DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
DEWEMOJI_FRONTEND_HEADER=web-v1
DEWEMOJI_FREE_DAILY_LIMIT=30
DEWEMOJI_GUMROAD_ENABLED=false
DEWEMOJI_MAYAR_ENABLED=false
```
## 2) Staging (real provider test)
```env
APP_ENV=staging
APP_DEBUG=false
APP_URL=https://staging.your-domain.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dewemojiAPI_DB
DB_USERNAME=...
DB_PASSWORD=...
DEWEMOJI_BILLING_MODE=live
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_PRO_KEYS=
DEWEMOJI_ALLOWED_ORIGINS=https://staging.your-domain.com,https://dewemoji.com,https://www.dewemoji.com,https://emoji.dewe.pw
DEWEMOJI_FRONTEND_HEADER=web-v1
DEWEMOJI_FREE_DAILY_LIMIT=30
DEWEMOJI_GUMROAD_ENABLED=true
DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify
DEWEMOJI_GUMROAD_PRODUCT_IDS=qfvcuT7RwcDn5Oi4KQcOBQ==,t8Kq1G5wzrd1KcYOgukpzw==
DEWEMOJI_MAYAR_ENABLED=true
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
DEWEMOJI_MAYAR_SECRET_KEY=...
```
## 3) Production
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://dewemoji.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dewemojiAPI_DB
DB_USERNAME=...
DB_PASSWORD=...
DEWEMOJI_BILLING_MODE=live
DEWEMOJI_LICENSE_ACCEPT_ALL=false
DEWEMOJI_PRO_KEYS=
DEWEMOJI_ALLOWED_ORIGINS=https://dewemoji.com,https://www.dewemoji.com
DEWEMOJI_FRONTEND_HEADER=web-v1
DEWEMOJI_FREE_DAILY_LIMIT=30
DEWEMOJI_GUMROAD_ENABLED=true
DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify
DEWEMOJI_GUMROAD_PRODUCT_IDS=qfvcuT7RwcDn5Oi4KQcOBQ==,t8Kq1G5wzrd1KcYOgukpzw==
DEWEMOJI_MAYAR_ENABLED=true
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
DEWEMOJI_MAYAR_SECRET_KEY=...
```
## 4) Legacy -> NativePHP key map
- `gateway_mode` -> `DEWEMOJI_BILLING_MODE`
- `allowed_origins[]` -> `DEWEMOJI_ALLOWED_ORIGINS` (comma-separated)
- `frontend_header` -> `DEWEMOJI_FRONTEND_HEADER`
- `free_daily_limit` -> `DEWEMOJI_FREE_DAILY_LIMIT`
- `gumroad.product_ids[]` -> `DEWEMOJI_GUMROAD_PRODUCT_IDS` (comma-separated)
- `gumroad.verify_url` -> `DEWEMOJI_GUMROAD_VERIFY_URL`
- `mayar.api_base` -> `DEWEMOJI_MAYAR_API_BASE`
- `mayar.endpoint_verify` -> `DEWEMOJI_MAYAR_ENDPOINT_VERIFY`
- `mayar.secret_key` -> `DEWEMOJI_MAYAR_SECRET_KEY`
## 5) Final checks
1. Run: `php artisan config:clear`
2. Run: `php artisan migrate --force`
3. Test verify endpoint with real key (Gumroad and Mayar)
4. Confirm `/v1/license/activate` + `/v1/license/deactivate`
5. Confirm frontend pages: `/`, `/browse`, `/support`, `/pricing`, `/api-docs`
## 6) Security note
Legacy `env.php` contains exposed secrets. Rotate all production credentials before final cutover:
- DB password
- Mayar key
- Turnstile keys
- OpenRouter key
- Redis password

View File

@@ -1,93 +0,0 @@
# Legacy API Spec (Retraced)
This spec is based on:
- backend code in `../dewemoji-api`
- extension usage in `../dewemoji-chrome-ext`
## 1) Implemented backend API (`dewemoji-api`)
### `GET /api/emojis`
- Params supported in backend code:
- `query` (search string)
- `category`
- `subcategory`
- `page` (default `1`)
- `limit` (default `50`, max `50`)
- `key` (optional license key for tier detection)
- Response:
```json
{
"items": [],
"page": 1,
"limit": 50,
"total": 0
}
```
- ETag + `Cache-Control: public, max-age=300`
### `GET /api/emoji/{slug}` and `GET /api/emoji?slug=...`
- Response: single emoji object
- Not found: `404 {"error":"not_found"}`
- ETag + `Cache-Control: public, max-age=300`
### `GET /api/categories`
- Response shape:
```json
{
"Smileys & Emotion": ["face-smiling", "face-affection"],
"People & Body": ["hand-fingers-open", "person"]
}
```
- `Cache-Control: public, max-age=3600`
### Router-level behavior
- CORS: `*`
- Methods: `GET, OPTIONS`
- Rate-limit: ~60 req/min/IP
- Rate-limit error: `429 {"error":"rate_limited"}`
## 2) Extension-consumed API contract (`dewemoji-chrome-ext`)
Extension points to: `https://api.dewemoji.com/v1`
### `GET /v1/emojis`
- Params sent by extension:
- `q`
- `category`
- `subcategory`
- `page`
- `limit` (Free 20 / Pro 50 in client logic)
- Expected response fields:
- top-level: `items`, `total`
- per-item: at least `emoji`, `name`, `category`, `subcategory`, `supports_skin_tone`
- Header read by extension: `X-Dewemoji-Tier`
### `GET /v1/categories`
Extension accepts two payload styles:
1) object map `{ "Category": ["sub"] }`
2) items array `{ "items": [{ "name": "Category", "subcategories": [...] }] }`
### `POST /v1/license/verify`
- Body sent:
```json
{
"key": "<license>",
"account_id": "<hashed-account-id>",
"version": "<extension-version>"
}
```
- Expected minimum response:
```json
{ "ok": true }
```
or
```json
{ "ok": false, "error": "..." }
```
## 3) Compatibility gaps to handle in rebuild
- Accept both `q` and `query` for search.
- Keep `/v1/*` contract stable for extension.
- Return `X-Dewemoji-Tier`.
- Preserve key item fields used by extension UI and tone logic.

View File

@@ -1,68 +0,0 @@
# Legacy Credentials and Config (Retraced)
## Source folders used
- `../dewemoji-api`
- `../dewemoji-chrome-ext`
- `../dewemoji-site`
## 1) Extension auth and credential flow
From `dewemoji-chrome-ext/panel.js`:
- User enters license key in settings.
- Extension verifies via `POST https://api.dewemoji.com/v1/license/verify`.
- License key is sent as payload and reused in headers for data requests.
Headers used on licensed requests:
- `Authorization: Bearer <licenseKey>`
- `X-License-Key: <licenseKey>`
- `X-Account-Id: <hashedAccountId>`
- `X-Dewemoji-Frontend: ext-v1` (always sent)
## 2) Local/sync storage keys (extension)
Local storage keys observed:
- `theme`
- `licenseValid`
- `licenseKey`
- `lastLicenseCheck`
- `actionMode`
- `searchCache`
- `accountId`
- `accountLabel`
- `profileUUID`
- usage key pattern: `usage_YYYYMMDD`
Sync storage keys observed:
- `preferredSkinTone`
- `toneLock`
## 3) Backend (`dewemoji-api`) auth status
From `helpers/auth.php`:
- Reads license from query `key` or bearer token.
- Validation hooks exist (`isValidGumroad`, `isValidMayar`) but are stubs returning `false`.
- Effective behavior in current code: fallback to free tier.
## 4) `dewemoji-site` credential/config status
- Site folder currently scaffold/empty files.
- No active credential logic currently implemented there.
## 5) Hardcoded secrets check result
In inspected source files:
- No committed private API secrets/tokens/passwords found.
- License key is user-provided at runtime (extension).
## 6) Rebuild config recommendations
For new NativePHP app, add env-managed secrets for:
- License provider API credentials
- Internal signing/app secrets
- Any DB credentials
And keep backward compatibility during migration for:
- `Authorization` bearer auth
- legacy `X-License-Key`
- `X-Account-Id`

View File

@@ -1,90 +0,0 @@
# Legacy System Audit (Retraced)
## Scope used for this audit
- `../dewemoji-api` (backend)
- `../dewemoji-chrome-ext` (chrome extension)
- `../dewemoji-site` (website)
## 1) Current reality by folder
### A) `dewemoji-api` (backend, currently functional)
- Contains working API routes and working web pages (`index.html`, `api-docs.html`).
- API files:
- `api/index.php`
- `api/emojis.php`
- `api/emoji.php`
- Helpers:
- `helpers/auth.php`
- `helpers/filters.php`
- Data pipeline:
- `tools/build-emojis.js`
- data in `data/*.json`
- Apache rewrite and security rules in `.htaccess`.
### B) `dewemoji-chrome-ext` (active extension)
- Manifest V3 extension with side panel UI.
- Main files:
- `manifest.json`
- `panel.js`, `panel.html`, `styles.css`
- `background.js`, `content.js`
- Uses remote API `https://api.dewemoji.com/v1`.
### C) `dewemoji-site` (website repo, currently scaffold)
- Folder exists with expected site structure (`index.php`, `api-docs.php`, `assets/`, `data/`, `helpers/`, etc.).
- Almost all files are currently **empty (0 bytes)**.
- Current state indicates scaffold/template placeholder, not active website logic.
## 2) Dataset snapshot (from `dewemoji-api`)
- Source: `../dewemoji-api/data/emojis.json`
- Emojis: **1910**
- Categories: **10** (`Activities`, `Animals & Nature`, `Component`, `Flags`, `Food & Drink`, `Objects`, `People & Body`, `Smileys & Emotion`, `Symbols`, `Travel & Places`)
- Subcategories: **99**
## 3) Feature inventory
### Backend/API (implemented in `dewemoji-api`)
- Public read API with pagination/filtering.
- `GET /api/emojis`
- `GET /api/emoji/{slug}`
- `GET /api/categories`
- CORS enabled for GET/OPTIONS.
- IP rate limit in router (60 requests / 60 seconds).
- ETag and cache headers.
### Chrome extension (implemented in `dewemoji-chrome-ext`)
- Search, category/subcategory filters.
- Free/Pro mode UX.
- License activation check flow.
- Copy / insert / auto mode.
- Skin tone preference + lock.
- Side panel and keyboard toggle command.
- Client cache + daily free usage tracking.
### Website (`dewemoji-site`)
- Not implemented yet (empty files).
- Current working website behavior still lives in `dewemoji-api` HTML/JS pages.
## 4) Important mismatches and risks
1. Query param mismatch in backend list endpoint:
- `api/emojis.php` reads `query`.
- Docs/client examples often send `q`.
2. Backend license validators are stubs:
- `isValidGumroad()` and `isValidMayar()` return `false`.
- Tier in this PHP backend effectively falls back to free.
3. Contract split:
- Backend folder exposes `/api/*`.
- Extension expects `/v1/*` on `api.dewemoji.com`.
4. Website source-of-truth gap:
- `dewemoji-site` is currently empty scaffold, so active website behavior is still in `dewemoji-api`.
## 5) Folder health snapshot
- `dewemoji-api`: 20 files, 20 non-empty (~4.3 MB)
- `dewemoji-chrome-ext`: 13 files, 13 non-empty (~135 KB)
- `dewemoji-site`: 40 files, 1 non-empty (`.gitignore`), rest empty scaffold

View File

@@ -1,71 +0,0 @@
# Dewemoji Localhost Run Note
Quick note so we can run Dewemoji locally anytime.
## Option A (recommended): Docker Compose
From project root:
```bash
docker compose up --build
```
Open:
- App: `http://127.0.0.1:8000`
Stop:
```bash
docker compose down
```
If you also want to remove DB volume:
```bash
docker compose down -v
```
## Option B: Native Laravel (without Docker)
From `app/`:
```bash
cp .env.example .env
composer install
npm install
php artisan key:generate
php artisan migrate
```
Run backend:
```bash
php artisan serve
```
Run frontend assets (new terminal):
```bash
npm run dev
```
Open:
- App: `http://127.0.0.1:8000`
## Important env notes
- `APP_URL=http://127.0.0.1:8000`
- For local DB simplicity, SQLite is fine.
- Dataset path should point to bundled file:
- `DEWEMOJI_DATA_PATH=/absolute/path/to/dewemoji/app/data/emojis.json`
- or leave empty (default uses bundled `app/data/emojis.json`).
## Quick checks
```bash
curl -s "http://127.0.0.1:8000/v1/health"
curl -s "http://127.0.0.1:8000/v1/emojis?limit=3"
```

View File

@@ -1,121 +0,0 @@
# Dewemoji Migration Test Guide (Provider + Frontend)
Follow this after pulling latest `main`.
## 1) Prepare local env
1. Open `app/.env`.
2. Set local app URL:
- `APP_URL=http://127.0.0.1:8000`
3. Start in sandbox first:
- `DEWEMOJI_BILLING_MODE=sandbox`
- `DEWEMOJI_LICENSE_ACCEPT_ALL=false`
4. Keep CORS local during testing:
- `DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000`
## 2) Run app
1. In `app/`, run:
- `php artisan migrate`
2. Start server:
- `php artisan serve --host=127.0.0.1 --port=8000`
3. Open `http://127.0.0.1:8000`.
## 3) Provider parity test (Live mode)
### A. Gumroad live mapping test
1. Switch to live mode in `.env`:
- `DEWEMOJI_BILLING_MODE=live`
- `DEWEMOJI_LICENSE_ACCEPT_ALL=false`
- `DEWEMOJI_PRO_KEYS=`
- `DEWEMOJI_GUMROAD_ENABLED=true`
- `DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify`
- `DEWEMOJI_GUMROAD_PRODUCT_IDS=<your_product_id>`
2. Restart `php artisan serve`.
3. Test verify:
```bash
curl -X POST "http://127.0.0.1:8000/v1/license/verify" \
-H "Content-Type: application/json" \
-d '{"key":"<real_gumroad_key>"}'
```
4. Expect `ok=true`, `source="gumroad"`, tier header `X-Dewemoji-Tier: pro`.
### B. Mayar live mapping test
1. In `.env`, configure:
- `DEWEMOJI_MAYAR_ENABLED=true`
- `DEWEMOJI_MAYAR_VERIFY_URL=<real_mayar_verify_url>`
- OR (`DEWEMOJI_MAYAR_API_BASE` + `DEWEMOJI_MAYAR_ENDPOINT_VERIFY`)
- `DEWEMOJI_MAYAR_API_KEY=<or secret key>`
2. Test verify:
```bash
curl -X POST "http://127.0.0.1:8000/v1/license/verify" \
-H "Content-Type: application/json" \
-d '{"key":"<real_mayar_key>"}'
```
3. Expect `ok=true`, `source="mayar"`.
### C. Negative check (important)
1. Verify with invalid key.
2. Expect `401` + `error="invalid_license"` + diagnostics in `details.gumroad` / `details.mayar`.
## 4) Activation lifecycle test
1. Verify key is valid first.
2. Activate device:
```bash
curl -X POST "http://127.0.0.1:8000/v1/license/activate" \
-H "Content-Type: application/json" \
-d '{"key":"<valid_key>","email":"you@example.com","product":"chrome","device_id":"chrome-profile-1"}'
```
3. Expect `ok=true`, `pro=true`.
4. Deactivate:
```bash
curl -X POST "http://127.0.0.1:8000/v1/license/deactivate" \
-H "Content-Type: application/json" \
-d '{"key":"<valid_key>","product":"chrome","device_id":"chrome-profile-1"}'
```
5. Expect `ok=true`.
## 5) Frontend parity test
Open and verify these URLs:
1. `/` (discover page)
2. `/browse`
3. `/<category>` example: `/animals`
4. `/<category>/<subcategory>` example: `/animals/animal-mammal`
5. `/emoji/grinning-face`
6. `/pricing`
7. `/api-docs`
8. `/support`
9. `/privacy`
10. `/terms`
11. `/robots.txt`
12. `/sitemap.xml`
Checks:
- Home search updates URL without full reload.
- Category routes prefill filters correctly.
- Pricing links point to Gumroad URLs.
- Support page loads and shows API/auth guidance.
- `robots.txt` contains sitemap URL.
- `sitemap.xml` includes core pages and emoji URLs.
## 6) Automated test pass
From `app/` run:
```bash
php artisan test
```
Expected: all tests pass.
## 7) Switch back to safe local mode (recommended)
After live-provider testing, return to:
- `DEWEMOJI_BILLING_MODE=sandbox`
- disable provider flags unless actively testing them.

View File

@@ -1,55 +0,0 @@
# Phase 1 Foundation Notes
## Scaffold status
- App path: `app/`
- Framework: Laravel 12
- Native package: `nativephp/desktop` installed
- Native scaffolding generated:
- `app/config/nativephp.php`
- `app/app/Providers/NativeAppServiceProvider.php`
## Local run commands
From `app/`:
- Web/Laravel dev:
- `composer run dev`
- Native desktop dev:
- `composer run native:dev`
## Environment strategy (initial)
Use `app/.env` for local only, and keep production secrets out of git.
Planned env groups:
1. App base
- `APP_NAME`, `APP_ENV`, `APP_DEBUG`, `APP_URL`
2. API compatibility
- `API_BASE_PATH=/v1`
- `API_RATE_LIMIT_PER_MINUTE`
3. Licensing
- `LICENSE_PROVIDER`
- `LICENSE_VERIFY_ENDPOINT`
- `LICENSE_VERIFY_SECRET`
4. NativePHP metadata
- `NATIVEPHP_APP_ID`
- `NATIVEPHP_APP_VERSION`
- `NATIVEPHP_APP_AUTHOR`
## Canonical data source decision
Start migration from:
- `../dewemoji-api/data/emojis.json`
Rationale:
- Most complete enriched dataset currently available.
- Already includes fields extension needs (`supports_skin_tone`, categories, subcategories, etc.).
## Known local prerequisite note
- Current machine Node is `v20.19.3`; it is enough for current scaffold, but we may upgrade to Node 22 later for stricter parity with newer NativePHP docs.

View File

@@ -1,43 +0,0 @@
# Phase 2 API Delivery
## Implemented endpoints (Laravel app)
Base path: `app/` project
- `GET /v1/categories`
- `GET /v1/emojis`
- `POST /v1/license/verify`
- `OPTIONS /v1/{any}` (CORS preflight)
## Compatibility behavior
- `/v1/*` routes are exposed without `/api` prefix (extension-compatible).
- `GET /v1/emojis` accepts both `q` and `query`.
- Responses include `X-Dewemoji-Tier` header.
## Current license mode
Environment-driven stub:
- `DEWEMOJI_LICENSE_ACCEPT_ALL=true` => any key is accepted as Pro.
- `DEWEMOJI_PRO_KEYS=key1,key2` => explicit key whitelist mode.
## Data source
Configured via:
- `DEWEMOJI_DATA_PATH` (defaults to `../../dewemoji-api/data/emojis.json` from `app/`).
## Test coverage
Added feature tests:
- `app/tests/Feature/ApiV1EndpointsTest.php`
- fixture: `app/tests/Fixtures/emojis.fixture.json`
Run:
```bash
cd app
php artisan test --filter=ApiV1EndpointsTest
```

View File

@@ -1,42 +0,0 @@
# Phase 3 Website Delivery
## Implemented routes
In `app/routes/web.php`:
- `GET /` (home)
- `GET /emoji/{slug}` (detail)
- `GET /api-docs`
- `GET /pricing`
- `GET /privacy`
- `GET /terms`
## Implemented pages
In `app/resources/views/site/`:
- `layout.blade.php`
- `home.blade.php`
- `emoji-detail.blade.php`
- `api-docs.blade.php`
- `pricing.blade.php`
- `privacy.blade.php`
- `terms.blade.php`
## Behavior
- Home page fetches categories and emojis from the new APIs:
- `/v1/categories`
- `/v1/emojis`
- Supports search/category/subcategory filtering and pagination via "Load more".
- Emoji cards link to server-rendered detail page by slug.
## Controller
- `app/app/Http/Controllers/Web/SiteController.php`
- Handles page rendering and slug-based emoji lookup from configured dataset.
## Test coverage
- `app/tests/Feature/SitePagesTest.php`
- Validates core pages, valid emoji detail, and 404 for invalid slug.

View File

@@ -1,287 +0,0 @@
# NativePHP Rebuild Progress (with `dewemoji-live` audit)
## Confirmed source folders
- Backend: `../dewemoji-api`
- Live backend reference: `../dewemoji-live-backend` (source of truth for current API + community/pro flows)
- Chrome extension: `../dewemoji-chrome-ext`
- Website (legacy scaffold): `../dewemoji-site`
- Live production reference: `../dewemoji-live` (source of truth for parity)
## Current baseline
- New rebuild app: `app/` (Laravel + NativePHP Desktop).
- API v1 routes exist in rebuild: `/v1/emojis`, `/v1/categories`, `/v1/license/verify`.
- Website routes currently in rebuild: `/`, `/emoji/{slug}`, `/api-docs`, `/pricing`, `/privacy`, `/terms`.
- Live site has additional behavior and SEO/route details that must be ported before full parity.
## Agreed strategy (locked)
- Build order: backend-first, then frontend integration.
- Community feature: included in migration scope, but implemented last after core/pro stabilization.
- Payment/provider mode: start in sandbox, document switch path to live (`SANDBOX -> LIVE`) in project docs.
- Database: fresh Laravel-first schema + import scripts from legacy data sources.
- Metrics endpoints: keep, but internal-only (admin token/IP allowlist), not public.
- Upgrade policy: migration is parity-first, but we will take safe opportunities to improve architecture, security, and observability.
## Phase checklist
### Phase 0 - Retrace and documentation
- [x] Revalidated source folders with corrected names.
- [x] Rebuilt initial docs against corrected folders.
- [x] Added live audit from `dewemoji-live`.
- [x] Added backend audit from `dewemoji-live-backend`.
### Phase 1 - Foundation
- [x] Initialize NativePHP app in `dewemoji/app`.
- [x] Install NativePHP Desktop scaffolding.
- [x] Define env/config strategy.
- [x] Use canonical emoji dataset as baseline.
### Phase 2 - API parity (extension first)
- [x] Implement `GET /v1/emojis`.
- [x] Implement `GET /v1/categories`.
- [x] Implement `POST /v1/license/verify` (temporary env-based validation).
- [x] Support both `q` and `query`.
- [x] Add/verify full response contract parity with live docs (`variants`, `related`, trimmed `keywords_en`, limit behavior by tier).
- [x] Match live cache/rate semantics baseline (`page=1` metering behavior, 401/429 payload shape, ETag/304 behavior).
- [x] Verify header compatibility baseline: `Authorization`, `X-License-Key`, `X-Account-Id`, `X-Dewemoji-Frontend`, `X-Dewemoji-Tier`.
- [x] Restrict CORS to configured origins (no default `*`).
- [x] Add missing live backend routes/contracts now present in production API:
- `/v1/license/activate`
- `/v1/license/deactivate`
- `/v1/health`
- `/v1/metrics` and `/v1/metrics-lite` (internal/admin decision needed: keep, secure, or remove)
- [x] Reconcile live route mismatch: added `/v1/emoji` and `/v1/emoji/{slug}` in rebuild API.
- [x] Add sandbox/live provider switch documentation and env examples (`BILLING_MODE=sandbox|live`, keys, callbacks, smoke test flow).
### Phase 3 - Website parity from `dewemoji-live`
- [x] Core pages exist in rebuild.
- [x] Add missing pages/routes: `/support`, `/browse`, pretty category routes (`/{category}` and `/{category}/{subcategory}`).
- [x] `/browse`, `/{category}`, `/{category}/{subcategory}` implemented in rebuild.
- [x] `/support` implemented in rebuild.
- [x] Keep URL behavior parity (canonical no-trailing-slash pages, redirect rules, pretty-to-query hydration).
- [x] no-trailing-slash redirect middleware and canonical link baseline implemented.
- [x] pretty route hydration wired into homepage initial filters + URL sync.
- [x] Port homepage behavior parity:
- API-backed filters (`q`, category, subcategory), URL sync, load-more pagination.
- [ ] API fallback when scoped search returns 0 (retry on `all` + hint).
- [ ] Port single emoji page parity:
- 404 `noindex` for missing.
- 410 + `X-Robots-Tag: noindex, noarchive` for policy-hidden emoji.
- skin-tone variant logic and optional tone path (`/emoji/{slug}/{tone}`).
- related fallback (same subcategory), prev/next navigation.
- details blocks: aliases, shortcodes, EN/ID keywords, copy interactions.
- curated blurbs support from `data/emoji_descriptions.json`.
- [x] Port legal/support content parity and FAQ schema blocks.
### Phase 4 - Pricing and payments
- [x] Keep pricing structure parity:
- Free, Pro subscription, Lifetime.
- Pro: `$3/mo` and yearly display `$27/yr` in UI.
- Lifetime: `$69`.
- [x] Preserve live Gumroad links:
- `https://dwindown.gumroad.com/l/dewemoji-pro-subscription`
- `https://dwindown.gumroad.com/l/dewemoji-pro-lifetime`
- [x] Keep IDR/Mayar messaging parity (manual-renew note).
- [ ] Implement real license lifecycle (activate/deactivate/verify + max 3 Chrome profiles) in new backend.
- [x] Implement real license lifecycle baseline in rebuild (`verify/activate/deactivate`, immutable owner binding behavior, max device cap).
- [ ] Implement provider verification parity:
- [x] Baseline service layer + env/config wiring + safe HTTP fallback
- [x] `/v1/license/verify` contract hardening: provider details + diagnostics (`details.gumroad`, `details.mayar`)
- [x] Gumroad verify API flow (final payload/contract parity with live provider account)
- [x] Mayar verify API flow (final payload/contract parity with live provider account)
- gateway mode switch (`sandbox` vs `live`)
- [x] Implement immutable license binding to user + multi-device activation policy parity.
### Phase 5 - SEO parity (must not disrupt GSC)
- [x] Preserve canonical strategy for all pages (including emoji detail + pretty category pages).
- [x] Add/verify meta + social tags parity baseline: title/description/OG/Twitter + theme color.
- [ ] Port JSON-LD strategy:
- [x] Global `WebSite` + `SearchAction` + Organization.
- [x] `TechArticle` baseline on `/api-docs`.
- [x] `Product` baseline on `/pricing`.
- [x] `FAQPage` baseline on `/support`.
- [x] `CreativeWork` + `BreadcrumbList` baseline on emoji pages.
- [x] Implement `robots.txt` parity and dynamic `sitemap.xml` baseline.
- [x] Ensure sitemap excludes policy-hidden emoji URLs (same filter policy as live).
- [x] Keep core indexed URLs stable: `/`, `/pricing`, `/api-docs`, `/support`, `/privacy`, `/terms`, `/emoji/{slug}`.
### Phase 6 - Analytics, consent, and compliance
- [ ] Re-implement cookie consent flow before analytics activation.
- [ ] Re-implement GA4 only on allowed production hosts (live uses `G-R7FYYRBVJK`).
- [ ] Keep privacy/terms statements aligned with live content.
- [ ] Add light/dark mode toggle in UI (persist user preference).
### Phase 7 - Data/ops pipelines
- [ ] Port blurb pipeline:
- `jobs/seed_blurbs_from_dataset.php`
- `jobs/sync_blurbs.php` (NocoDB approved blurbs sync).
- [ ] Define NativePHP/Laravel replacement for live file microcache (`cache/emoji/*.html`) if still needed for SEO performance.
- [ ] Add rebuild-side commands/jobs for sitemap regeneration and cache warmup.
- [ ] Port backend dataset pipelines from `dewemoji-live-backend/jobs`:
- JSON -> SQL import (`import_emojis_json_to_sql.php`)
- SQL -> JSON build (`build_emojis_json_from_sql.php`)
- Keywords index build (`build_keywords_json_from_sql.php`)
- Unicode parity validation (`validate_emojis_against_unicode.php`)
- License expiry revocation cron (`check_license_expiry.php`)
- [x] Added live SQL import command for app DB (`php artisan dewemoji:import-live-sql`).
- [x] Added legacy table migrations + JSON normalization for import.
- [ ] Port legacy JSON generator as Laravel command (cache-first strategy):
- Source: `dewemoji-live-backend/jobs/build_emojis_json_from_sql.php`
- Output: `app/data/emojis.json` (default API dataset)
- [ ] Add scheduled task (Coolify) for JSON rebuild (daily or on-demand).
### Phase 8 - Community feature migration
- [ ] Port contributor auth flow:
- magic link token issue/verify (`/v1/contrib/auth/request`, `/v1/contrib/auth/verify`)
- stateless HMAC token strategy or Laravel equivalent.
- [ ] Port contribution flows:
- suggest keywords (`/v1/contrib/suggest`)
- private -> public promotion (`/v1/contrib/make-public`)
- list and search (`/v1/contrib/list`, `/v1/contrib/search`)
- voting + pending queue (`/v1/contrib/vote`, `/v1/keywords/pending`)
- [ ] Port moderation protections:
- Turnstile verification
- AI guard moderation pipeline (OpenRouter + usage caps/cache)
- Redis/APCu rate limiting (vote/suggest/publish paths)
- [ ] Port auto-moderation behavior:
- score thresholds (`vote_auto_approve_score`, `vote_auto_reject_score`)
- status/visibility transitions (`private`, `public_pending`, `public`, `approved`, `rejected`)
- [ ] Fix live bug during migration: `/v1/contrib/search` is nested under `/v1/keywords/pending` block in current controller, so route behavior should be revalidated in rebuild.
### Phase 9 - Extension integration and release
- [ ] Point `dewemoji-chrome-ext` to new API host.
- [ ] Validate free/pro flow end-to-end with real license checks.
- [ ] Run parity QA on tone handling, insert/copy behavior, and API limits.
- [ ] Prepare migration + rollback checklist (DNS/host switch, redirects, monitoring).
### Phase 10 - Deployment hardening (Coolify/ops)
- [x] Define single-stack deployment template (app + mysql) in the same destination/network.
- [x] Add startup sequence for server deployment:
- wait for DB readiness
- run migrations automatically (idempotent)
- start web server
- [ ] Keep local/dev simple with SQLite profile; keep staging/prod profile on MySQL.
- [ ] Add deployment runbook with minimum required env vars and health verification steps.
- [ ] Add post-deploy smoke checks (`/`, `/v1/health`, `/v1/emojis`, `/robots.txt`, `/sitemap.xml`).
- [x] Add container healthcheck for app service using `/v1/health`.
- [x] Staging data sync validated using R2 upload + import flow.
## Recent implementation update
- Added new API endpoints in rebuild:
- `GET /v1/emoji`
- `GET /v1/emoji/{slug}`
- `POST /v1/license/activate`
- `POST /v1/license/deactivate`
- `GET /v1/health`
- `GET /v1/metrics`
- `GET /v1/metrics-lite`
- Added internal-protected metrics controller (`token` or IP allowlist).
- Added sandbox/live billing mode documentation: `billing-sandbox-live.md`.
- Added fresh Laravel migrations for core backend state:
- `licenses`
- `license_activations`
- `usage_logs`
- Added `LicenseVerificationService` and wired controllers to use one verification path:
- sandbox mode
- live key-list mode
- baseline Gumroad/Mayar provider calls (+ local stub test keys)
- Added SEO-safe route/canonical baseline:
- `/browse` route
- pretty category routes (`/{category}`, `/{category}/{subcategory}`)
- trailing slash -> canonical path redirect (301)
- canonical `<link>` output from layout
- Added SEO assets baseline in rebuild:
- `/robots.txt` route
- `/sitemap.xml` route generated from dataset
- meta/OG/Twitter fields in shared layout
- JSON-LD blocks on `/pricing`, `/support`, `/api-docs`, and emoji detail page
- Added Docker deployment baseline for one-pack deployment:
- `docker-compose.yml` (Laravel app + MySQL 8 with healthcheck + persistent volumes)
- `app/Dockerfile` (build Vite assets + Composer deps, serve via Apache)
- `app/docker/entrypoint.sh` (DB wait + auto-migrate + startup)
- Added billing provider hardening:
- Gumroad: validate `is_valid`, reject refunded/chargebacked purchases, preserve product-id matching.
- Mayar: broaden request payload compatibility, normalize multiple response shapes, optional product-id allowlist (`DEWEMOJI_MAYAR_PRODUCT_IDS`).
- Added live SQL import tooling:
- New migrations for legacy tables + import service
- JSON normalization for legacy `meta_json`/`payload`/`raw_response` fields
- API list responses now always include `plan` for pro/free tests.
- Added SEO polish:
- Global JSON-LD for `WebSite`, `SearchAction`, and `Organization` in shared layout.
- Static `public/robots.txt` now explicitly allows crawling and exposes sitemap URL.
## Cache strategy decision (open)
- Current API reads from `app/data/emojis.json`.
- Live legacy server regenerates that JSON from MySQL via cron:
- `dewemoji-live-backend/jobs/build_emojis_json_from_sql.php`
- Recommendation: keep JSON cache (fast + stable) and port the generator into Laravel,
then schedule it in Coolify (daily or on-demand).
## Live audit highlights (reference)
- Live web routes in `dewemoji-live/public_html`: `/`, `/emoji/{slug}`, `/browse`, `/pricing`, `/api-docs`, `/support`, `/privacy`, `/terms`, `/sitemap.xml`.
- Rewrite rules and canonicalization live in `dewemoji-live/public_html/.htaccess`.
- SEO assets:
- `dewemoji-live/public_html/includes/head.php`
- `dewemoji-live/public_html/sitemap.xml.php`
- `dewemoji-live/public_html/robots.txt`
- Emoji page implementation reference:
- `dewemoji-live/public_html/emoji.php`
- includes microcache + structured data + policy filtering.
- Pricing + payment references:
- `dewemoji-live/public_html/pricing.php`
- `dewemoji-live/public_html/support.php`
- `dewemoji-live/public_html/privacy.php`
- `dewemoji-live/public_html/terms.php`
- Blurb data + jobs:
- `dewemoji-live/public_html/data/emoji_descriptions.json`
- `dewemoji-live/jobs/sync_blurbs.php`
- `dewemoji-live/jobs/seed_blurbs_from_dataset.php`
## Live backend audit highlights (`dewemoji-live-backend`)
- Backend architecture is a custom PHP router (`public_html/public/index.php`) with controller-per-endpoint files and shared helpers.
- Main live API surface discovered:
- `/v1/emojis`, `/v1/categories`
- `/v1/license/verify`, `/v1/license/activate`, `/v1/license/deactivate`
- `/v1/contrib/*` community endpoints (suggest/list/vote/auth/make-public/search)
- `/v1/keywords/pending`
- `/v1/health`, `/v1/metrics`, `/v1/metrics-lite`
- Data mode is hybrid:
- API reads from JSON dataset (`public_html/app/data/emojis.json`) for emoji search.
- Licensing, usage logs, and community data read/write in MySQL.
- Redis/APCu/in-memory used for runtime rate-limiting fallback chain.
- Pro logic currently exists in live backend:
- plan resolution from license key + first-party whitelist headers/origin.
- pro/free limits for API and contribution quotas.
- device activation model with max active devices per license/product.
- Community feature maturity:
- keyword contribution flow exists with private/public states.
- voting and auto-approval/rejection thresholds implemented.
- Turnstile + AI moderation + rate limiting integrated.
- still has fragile areas that should be normalized in Laravel service layer.
- Database model inferred from code (must be migrated with proper Laravel migrations):
- `emojis`, `emoji_aliases`, `emoji_shortcodes`, `emoji_usage_examples`, `emoji_related`, `emoji_intent_tags`, `emoji_search_tokens`
- `emoji_keywords`, `keyword_votes`, `moderation_events`
- `users`
- `licenses`, `license_activations`, `usage_logs`
- `ai_guard_logs`, `ai_provider_usage`, `ai_lang_cache`
## Security and configuration migration requirements
- Current live backend keeps many secrets directly in `public_html/config/env.php` (DB, Redis, Turnstile, payment providers, OpenRouter).
- Rebuild must move all secrets to `.env`, rotate exposed credentials, and remove committed secret values from repo history.
- Metrics endpoints currently appear open by default; rebuild should protect admin/internal endpoints with auth or network policy.
- Add internal observability baseline in rebuild:
- structured request logging
- protected metrics endpoint(s)
- deploy healthcheck endpoint
## Important note on Gumroad API tracing
- In `dewemoji-live/public_html/helpers/auth.php`, Gumroad/Mayar validation is currently a stub (`return false`), so live verification logic is not fully present in this folder.
- There is legacy local activation SQL flow in `dewemoji-live/public_html/db.php` (activate/deactivate/verify + device cap), which should be used only as behavioral reference for rebuild design.

View File

@@ -1,188 +0,0 @@
# Redirection Plan — Dewemoji Pivot (2026)
**Source of truth:** `dewemoji-direction-2026.md` (Feb 8, 2026)
This document maps the **2026 pivot** (“personal emoji library + sync”) against what we have **already built** in the rebuild app, then defines a concrete redirection plan.
---
## 1) Pivot Summary (as confirmed)
**New product north star:**
- Dewemoji is a **personal emoji library**.
- Users create private keywords in any language (including slang and nonLatin scripts), sync across devices, and **own their vocabulary**.
- Public discovery remains free (EN/ID + semantic) as a growth engine.
- No public/community submissions → no moderation burden.
**Monetization shift:**
- Free: public search only.
- Paid: private keyword library + sync + API keys + dashboard.
- Rename **Pro → Personal**.
---
## 2) What Weve Already Built (Current State)
### ✅ Built & aligned
- Public emoji discovery (JSON dataset, EN + ID keywords).
- API endpoints: `/v1/emojis`, `/v1/categories`, `/v1/emoji`, `/v1/health`.
- Frontend search + browse + emoji detail pages.
- Basic SEO + sitemap + robots.
- License verification/activation endpoints (legacystyle) and supporting services.
- The rebuild is already cachefirst (`app/data/emojis.json`).
### ⚠️ Built but **misaligned** with pivot
- **Licensebased Pro system** (Gumroad/Mayar validation).
- **Device activation** + license tables intended for legacy Pro (not Personal plan).
- “Pro” pricing page and marketing copy (old pricing, old narrative).
- API docs + support pages referencing license key flow.
### ❌ Not built yet (required by pivot)
- User accounts (register/login/session).
- Personal tier subscription model (Stripe + subscriptions table).
- API key management tied to user (generate/revoke).
- Private keyword CRUD (user keywords table).
- Personal dashboard (keywords, API keys, billing).
- Extension account linking + private keyword sync.
- Public endpoint whitelisting (origin + extension UA) + soft throttling.
---
## 3) Directional Conflicts to Resolve
| Area | Current | Pivot Requirement | Decision |
|------|---------|------------------|----------|
| Monetization | Pro license keys | Personal subscription | **Replace** license key system with account + API key model |
| Identity | None | User accounts | **Add** auth system + dashboard |
| Keywords | Public only | Private per user | **Add** `user_keywords` + blended search |
| API Access | License key | User API keys | **Replace** authorization method |
| Community | Planned public contributions | Privateonly | **Remove** community feature scope |
| Pricing | $3/mo + $27/yr + $69 lifetime | $4.99/mo + $49/yr + $99 lifetime | **Update** pricing + copy |
---
## 4) Redirection Plan (Phased)
### Phase A — Foundation (Backend) ✅ *Start here*
Goal: replace license model with **account + API key** architecture.
**Backend tasks**
- Add tables: `users`, `user_api_keys`, `user_keywords`, `subscriptions`.
- Add auth endpoints:
- `POST /v1/user/register`
- `POST /v1/user/login`
- `POST /v1/user/logout`
- Add API key endpoints:
- `GET /v1/user/apikeys`
- `POST /v1/user/apikeys`
- `DELETE /v1/user/apikeys/:key`
- Add private keyword endpoints:
- `GET /v1/keywords`
- `POST /v1/keywords`
- `DELETE /v1/keywords/:id`
- Add `GET /v1/search?private=true` to **blend private + public** results.
- Implement **origin/UA whitelist** for public endpoints and soft throttling.
**What to freeze**
- Legacy license endpoints stay online temporarily but **deprecated**.
- No new features on Gumroad/Mayar flow.
---
### Phase B — Dashboard UI (Web)
Goal: provide a **personal workspace** for private keywords and API keys.
**Frontend tasks**
- Add `/register`, `/login`, `/dashboard`.
- Dashboard tabs:
- **My Keywords** (CRUD, bulk import/export)
- **API Keys** (generate/revoke)
- **Billing** (plan status, upgrade links)
- Update emoji detail page:
- “Add to My Keywords” for Personal users.
- Show personal keywords for this emoji (if any).
---
### Phase C — Extension Integration
Goal: enable personal keywords in extension + sync.
**Extension tasks**
- “Link Account” flow in extension.
- Store API key in `chrome.storage.sync`.
- Include `Authorization: Bearer dew_…` header when logged in.
- Blend private + public results.
- Upgrade prompt on missing keyword: “Create this in Personal?”
---
### Phase D — Payments + Plan Rename
Goal: Personal tier + billing system.
**Billing tasks**
- PayPal subscription plan (Monthly/Annual) + webhook handler.
- Keep pricing **admincontrolled** (see Admin Dashboard plan).
- Webhooks → populate `subscriptions`.
- Plan rename everywhere: **Pro → Personal**.
- Pricing page update.
---
### Phase E — Deprecate Legacy Licenses
Goal: cut off old Gumroad model cleanly.
**Migration tasks**
- “Paste license → autocreate account → grant Personal (lifetime)” tool.
- Email existing license users.
- Sunset date for legacy validation.
---
## 5) Immediate File Updates (Recommended Next)
- Update **API docs** to mention account/API keys (not licenses).
- Update **Pricing page** to Personal tier and new price points.
- Update **Support/Terms/Privacy** copy with Personal tier language.
- Update **community-plan.md** (already aligned with privateonly direction).
---
## 6) Risk Notes
- The pivot **removes public contribution features** and their associated moderation burden. This is consistent with the new revenue model but changes the original story. Make sure homepage and extension copy reflect **personal library** first.
- Referrer whitelist + soft throttle is required to keep free endpoints open without abuse.
- **Public unlimited usage** should only be granted to **real extension installs**. Plan: use Chrome Verified Access / `chrome.identity.getCertificationToken()` to prove the request is from a legitimate Web Store install before allowing unlimited public access.
- **Private keywords + sync** always require **user authentication** (magiclink/OTP) — no exceptions.
---
## 7) Quick Comparison Snapshot
**What we should keep:**
- Public emoji search + dataset caching.
- Site UI/SEO baseline.
- API response contract (minus license semantics).
**What we should replace:**
- Gumroad/Mayar license verification.
- Pro/lifetime license activation.
- Community submissions/voting.
**What we should add:**
- User accounts + API keys.
- Private keyword library + sync.
- Dashboard + billing.
---
## 8) Decision Checkpoints
Before coding Phase A, confirm:
1. **Plan name** is officially “Personal” (not Pro).
2. **API key format** (`dew_…`) accepted.
3. Public endpoints remain unlimited for **dewemoji.com + extension**.
4. Stripe is the primary payment provider.
---
If you want, I can immediately convert this into a task checklist by repo folder (backend, web, extension) and start Phase A scaffolding.

View File

@@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CAPACITOR_DIR="${ROOT_DIR}/dewemoji-capacitor"
ANDROID_DIR="${ROOT_DIR}/dewemoji-capacitor/android" ANDROID_DIR="${ROOT_DIR}/dewemoji-capacitor/android"
APP_GRADLE="${ANDROID_DIR}/app/build.gradle" APP_GRADLE="${ANDROID_DIR}/app/build.gradle"
DIST_DIR="${ROOT_DIR}/dewemoji-capacitor/dist/apk" DIST_DIR="${ROOT_DIR}/dewemoji-capacitor/dist/apk"
@@ -20,6 +21,15 @@ fi
mkdir -p "${DIST_DIR}" mkdir -p "${DIST_DIR}"
echo "== Build local web assets =="
npm --prefix "${CAPACITOR_DIR}" run build
echo "== Sync Capacitor Android project =="
(
cd "${CAPACITOR_DIR}"
npx --yes cap sync android
)
echo "== Build release APK ==" echo "== Build release APK =="
( (
cd "${ANDROID_DIR}" cd "${ANDROID_DIR}"

View File

@@ -20,6 +20,9 @@ Required env:
Optional env: Optional env:
R2_PUBLIC_BASE_URL (example: https://downloads.dewemoji.com) R2_PUBLIC_BASE_URL (example: https://downloads.dewemoji.com)
DEWEMOJI_APK_URL (default: https://dewemoji.com/downloads/dewemoji-latest.apk) DEWEMOJI_APK_URL (default: https://dewemoji.com/downloads/dewemoji-latest.apk)
APK_VERSIONED_CACHE_CONTROL (default: public,max-age=31536000,immutable)
APK_LATEST_CACHE_CONTROL (default: no-store,max-age=0,must-revalidate)
APK_VERSION_JSON_CACHE_CONTROL (default: no-store,max-age=0,must-revalidate)
USAGE USAGE
} }
@@ -82,6 +85,9 @@ latest_key="apk/dewemoji-latest.apk"
version_json_key="apk/version.json" version_json_key="apk/version.json"
apk_url="${DEWEMOJI_APK_URL:-https://dewemoji.com/downloads/dewemoji-latest.apk}" apk_url="${DEWEMOJI_APK_URL:-https://dewemoji.com/downloads/dewemoji-latest.apk}"
versioned_cache_control="${APK_VERSIONED_CACHE_CONTROL:-public,max-age=31536000,immutable}"
latest_cache_control="${APK_LATEST_CACHE_CONTROL:-no-store,max-age=0,must-revalidate}"
version_json_cache_control="${APK_VERSION_JSON_CACHE_CONTROL:-no-store,max-age=0,must-revalidate}"
version_json_path="${tmp_dir}/version.json" version_json_path="${tmp_dir}/version.json"
"${MAKE_VERSION_SCRIPT}" \ "${MAKE_VERSION_SCRIPT}" \
--version-name "${version_name}" \ --version-name "${version_name}" \
@@ -94,13 +100,19 @@ version_json_path="${tmp_dir}/version.json"
--out "${version_json_path}" --out "${version_json_path}"
echo "== Upload versioned APK ==" echo "== Upload versioned APK =="
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${versioned_key}" --content-type application/vnd.android.package-archive aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${versioned_key}" \
--content-type application/vnd.android.package-archive \
--cache-control "${versioned_cache_control}"
echo "== Upload latest APK alias ==" echo "== Upload latest APK alias =="
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${latest_key}" --content-type application/vnd.android.package-archive aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${latest_key}" \
--content-type application/vnd.android.package-archive \
--cache-control "${latest_cache_control}"
echo "== Upload version metadata ==" echo "== Upload version metadata =="
aws --endpoint-url "${endpoint}" s3 cp "${version_json_path}" "s3://${R2_BUCKET}/${version_json_key}" --content-type application/json --cache-control no-store aws --endpoint-url "${endpoint}" s3 cp "${version_json_path}" "s3://${R2_BUCKET}/${version_json_key}" \
--content-type application/json \
--cache-control "${version_json_cache_control}"
echo "Published to R2 bucket: ${R2_BUCKET}" echo "Published to R2 bucket: ${R2_BUCKET}"
echo "Versioned APK key: ${versioned_key}" echo "Versioned APK key: ${versioned_key}"

View File

@@ -1,96 +0,0 @@
# Sequel Ace + Coolify Staging MySQL (Reliable Access Guide)
This guide is for when Sequel Ace SSH mode fails against a Coolify-hosted app where MySQL is **internal-only**.
## Why Sequel Ace built-in SSH can fail on Coolify
In many Coolify setups, MySQL is not exposed on host `127.0.0.1:3306`.
It runs only inside Docker network (service name like `mysql`), so direct SSH-to-host + DB host `127.0.0.1` will fail.
## Recommended method (works with internal-only MySQL)
Use a terminal tunnel to the **current MySQL container IP**, then connect Sequel Ace to local `127.0.0.1:3307`.
## 1) On your Mac: get MySQL container IP from server
Replace:
- `SERVER_USER`
- `SERVER_HOST`
```bash
MYSQL_IP=$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')")
echo "$MYSQL_IP"
```
If this prints an IP (example `172.18.0.5`), continue.
## 2) Create SSH tunnel (keep terminal open)
```bash
ssh -N -L 3307:${MYSQL_IP}:3306 SERVER_USER@SERVER_HOST
```
Keep this terminal open while using Sequel Ace.
## 3) Create Sequel Ace connection
Use **Standard** (not SSH) because tunnel is already created:
- Name: `Dewemoji Staging`
- Host: `127.0.0.1`
- Port: `3307`
- User: your MySQL user (example `dewesql`)
- Password: your MySQL password
- Database: `dewemoji`
Click **Test Connection** then **Connect**.
## 4) Verify database quickly
Run in Sequel Ace:
```sql
SHOW TABLES;
SELECT NOW();
```
## Troubleshooting
### A) `Connection refused`
- Tunnel terminal is closed, or command failed.
- Re-run step 2.
### B) `Access denied for user`
- Wrong DB username/password.
- Confirm app/coolify env values for DB credentials.
### C) `Unknown database`
- Wrong DB name.
- Check with:
```sql
SHOW DATABASES;
```
### D) No `MYSQL_IP` returned
- Server user cannot run Docker commands.
- Test manually:
```bash
ssh SERVER_USER@SERVER_HOST "docker ps --format '{{.Names}}'"
```
- If permission denied, use a user with Docker access.
### E) Tunnel worked yesterday but not today
- MySQL container IP changed after redeploy/restart.
- Re-run step 1 and step 2 each session.
## Optional: one-liner (resolve + tunnel)
```bash
ssh -N -L 3307:$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')"):3306 SERVER_USER@SERVER_HOST
```
## Security note
Do not expose MySQL publicly in Coolify just for GUI access.
Tunnel-only access is safer for staging and production.

View File

@@ -1,44 +0,0 @@
# Staging Sync Checklist (MySQL)
This is the exact, minimal checklist to sync **live SQL → staging MySQL**.
## 1) Point app to MySQL
Edit `.env` (or set in Coolify ENV):
```
DB_CONNECTION=mysql
DB_HOST=YOUR_MYSQL_HOST
DB_PORT=3306
DB_DATABASE=YOUR_DB
DB_USERNAME=YOUR_USER
DB_PASSWORD=YOUR_PASS
```
## 2) Run migrations + import (inside Coolify app container)
```bash
cd /var/www/html
php artisan migrate
php artisan dewemoji:import-live-sql /var/www/html/dewemojiAPI_DB.sql --truncate
```
If your SQL file is elsewhere, locate it first:
```bash
find / -name "dewemojiAPI_DB.sql" 2>/dev/null
```
## 3) Quick sanity checks
```bash
php artisan tinker --execute="echo DB::table('emojis')->count().PHP_EOL;"
php artisan tinker --execute="echo DB::table('emoji_keywords')->count().PHP_EOL;"
```
Expected: `emojis` ~ 2131, `emoji_keywords` ~ 13420.
## Notes
- Live `users` + `sessions` are imported into `legacy_users` + `legacy_sessions`.
- Licenses/activations/usage logs are imported into current tables for parity.

View File

@@ -1,70 +0,0 @@
# TablePlus (Paid) or Sequel Ace (Free) + SSH Tunnel Guide (MySQL)
This is a complete, stepbystep guide to access your **internal Coolify MySQL** from your Mac using **SSH tunnel + TablePlus or Sequel Ace**.
## 1) Install a GUI client
Choose one:
### Option A — Sequel Ace (Free)
```bash
brew install --cask sequel-ace
```
### Option B — TablePlus (Trial/paid)
Pick one method:
**Option A — Homebrew**
```bash
brew install --cask tableplus
```
**Option B — Download App**
- Download from TablePlus website and install normally.
## 2) Create the SSH tunnel (Mac Terminal)
Replace `USER` and `YOUR_SERVER` with your SSH credentials:
```bash
ssh -N -L 3307:127.0.0.1:3306 USER@YOUR_SERVER
```
Keep this terminal **open** while you use TablePlus.
If you close it, the tunnel disconnects.
## 3) Open TablePlus or Sequel Ace and create connection
In the app:
1) Click **Create a new connection**
2) Choose **MySQL**
3) Fill in:
- **Name:** Dewemoji (or anything)
- **Host:** `127.0.0.1`
- **Port:** `3307`
- **User:** `dewesql` (or your MySQL user)
- **Password:** (your MySQL password)
- **Database:** `dewemoji` (or your DB name)
4) Click **Test** → should be green
5) Click **Connect**
## 4) Done
You now have full GUI access to the internal MySQL.
## Troubleshooting
**“Connection refused”**
- Tunnel not running (step 2). Keep it open.
**“Access denied”**
- Wrong MySQL username/password.
**No tables**
- Wrong database name.
---
If you want, I can add a short “quick commands” section for `mysql` CLI too.