Update pricing UX, billing flows, and API rules

This commit is contained in:
Dwindi Ramadhana
2026-02-12 00:52:40 +07:00
parent cf065fab1e
commit a905256353
202 changed files with 22348 additions and 301 deletions

View File

@@ -47,7 +47,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_MAILER=mailketing
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
@@ -55,6 +55,9 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAILKETING_API_URL=https://api.mailketing.co.id/api/v1/send
MAILKETING_API_TOKEN=
MAILKETING_TIMEOUT=10
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -95,3 +98,27 @@ DEWEMOJI_FRONTEND_HEADER=web-v1
DEWEMOJI_METRICS_ENABLED=true
DEWEMOJI_METRICS_TOKEN=
DEWEMOJI_METRICS_ALLOW_IPS=127.0.0.1,::1
DEWEMOJI_USD_RATE=15000
DEWEMOJI_QRIS_URL=
DEWEMOJI_PAYPAL_URL=
DEWEMOJI_PAYPAL_ENABLED=false
DEWEMOJI_PAYPAL_TIMEOUT=10
DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID=
DEWEMOJI_PAYPAL_SANDBOX_CLIENT_SECRET=
DEWEMOJI_PAYPAL_SANDBOX_WEBHOOK_ID=
DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_MONTHLY=
DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_ANNUAL=
DEWEMOJI_PAYPAL_LIVE_CLIENT_ID=
DEWEMOJI_PAYPAL_LIVE_CLIENT_SECRET=
DEWEMOJI_PAYPAL_LIVE_WEBHOOK_ID=
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_MONTHLY=
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_ANNUAL=
DEWEMOJI_PAYPAL_SANDBOX_API_BASE=https://api-m.sandbox.paypal.com
DEWEMOJI_PAYPAL_SANDBOX_WEB_BASE=https://www.sandbox.paypal.com
DEWEMOJI_PAYPAL_LIVE_API_BASE=https://api-m.paypal.com
DEWEMOJI_PAYPAL_LIVE_WEB_BASE=https://www.paypal.com
DEWEMOJI_PAKASIR_ENABLED=false
DEWEMOJI_PAKASIR_API_BASE=https://app.pakasir.com
DEWEMOJI_PAKASIR_API_KEY=
DEWEMOJI_PAKASIR_PROJECT=
DEWEMOJI_PAKASIR_TIMEOUT=10

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminAnalyticsController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function overview(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$totalUsers = User::count();
$personalUsers = User::where('tier', 'personal')->count();
$apiKeys = UserApiKey::count();
$keywords = UserKeyword::count();
$subscriptions = Subscription::count();
$activeSubscriptions = Subscription::where('status', 'active')->count();
$pricingPlans = PricingPlan::count();
$webhooks = WebhookEvent::count();
return response()->json([
'ok' => true,
'metrics' => [
'users_total' => $totalUsers,
'users_personal' => $personalUsers,
'api_keys_total' => $apiKeys,
'keywords_total' => $keywords,
'subscriptions_total' => $subscriptions,
'subscriptions_active' => $activeSubscriptions,
'pricing_plans_total' => $pricingPlans,
'webhook_events_total' => $webhooks,
],
]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingChange;
use App\Models\PricingPlan;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminPricingController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$adminToken = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$plans = PricingPlan::orderBy('id')->get();
return response()->json([
'ok' => true,
'plans' => $plans,
]);
}
public function changes(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$limit = max((int) $request->query('limit', 20), 1);
$items = PricingChange::orderByDesc('id')
->limit($limit)
->get();
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function update(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$data = $request->validate([
'admin_ref' => 'nullable|string|max:120',
'plans' => 'required|array|min:1',
'plans.*.code' => 'required|string|max:30',
'plans.*.name' => 'required|string|max:50',
'plans.*.currency' => 'required|string|max:10',
'plans.*.amount' => 'required|integer|min:0',
'plans.*.period' => 'nullable|string|max:20',
'plans.*.status' => 'nullable|string|max:20',
'plans.*.meta' => 'nullable|array',
]);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($data): void {
foreach ($data['plans'] as $plan) {
PricingPlan::updateOrCreate(
['code' => $plan['code']],
[
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'] ?? null,
'status' => $plan['status'] ?? 'active',
'meta' => $plan['meta'] ?? null,
]
);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => $data['admin_ref'] ?? null,
'before' => $before,
'after' => $after,
]);
return response()->json([
'ok' => true,
'plans' => $after,
]);
}
public function reset(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$defaults = config('dewemoji.pricing.defaults', []);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($defaults): void {
PricingPlan::query()->delete();
foreach ($defaults as $plan) {
PricingPlan::create([
'code' => $plan['code'],
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'],
'status' => $plan['status'],
'meta' => $plan['meta'] ?? null,
]);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) $request->header('X-Admin-Ref', ''),
'before' => $before,
'after' => $after,
]);
return response()->json([
'ok' => true,
'plans' => $after,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\System\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminSettingsController extends Controller
{
public function __construct(private readonly SettingsService $settings)
{
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$keys = $request->query('keys');
$all = $this->settings->all();
if (is_string($keys) && $keys !== '') {
$filter = array_filter(array_map('trim', explode(',', $keys)));
$all = array_intersect_key($all, array_flip($filter));
}
return response()->json(['ok' => true, 'settings' => $all]);
}
public function update(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$payload = $request->input('settings');
if (!is_array($payload)) {
return response()->json(['ok' => false, 'error' => 'invalid_payload'], 422);
}
$adminRef = (string) $request->header('X-Admin-Ref', '');
$this->settings->setMany($payload, $adminRef !== '' ? $adminRef : null);
return response()->json([
'ok' => true,
'settings' => $this->settings->all(),
]);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class AdminSubscriptionController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$query = Subscription::query()->with('user:id,email,name,tier');
if ($userId = $request->query('user_id')) {
$query->where('user_id', (int) $userId);
}
if ($email = $request->query('email')) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$limit = min(max((int) $request->query('limit', 50), 1), 200);
$items = $query->orderByDesc('id')->limit($limit)->get();
return response()->json(['ok' => true, 'items' => $items]);
}
public function grant(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$user = $this->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$plan = (string) $request->input('plan', 'personal');
$status = (string) $request->input('status', 'active');
$provider = (string) $request->input('provider', 'admin');
$providerRef = (string) $request->input('provider_ref', '');
$startedAt = $this->parseDate($request->input('started_at')) ?? now();
$expiresAt = $this->parseDate($request->input('expires_at'));
$sub = Subscription::create([
'user_id' => $user->id,
'plan' => $plan,
'status' => $status,
'provider' => $provider,
'provider_ref' => $providerRef !== '' ? $providerRef : null,
'started_at' => $startedAt,
'expires_at' => $expiresAt,
]);
if ($status === 'active') {
$user->update(['tier' => 'personal']);
}
return response()->json(['ok' => true, 'subscription' => $sub]);
}
public function revoke(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$id = (int) $request->input('id', 0);
$now = now();
if ($id > 0) {
$sub = Subscription::find($id);
if (!$sub) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$sub->update(['status' => 'revoked', 'expires_at' => $now]);
$this->syncUserTier($sub->user_id);
return response()->json(['ok' => true, 'revoked' => true]);
}
$user = $this->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
Subscription::where('user_id', $user->id)
->where('status', 'active')
->update(['status' => 'revoked', 'expires_at' => $now]);
$this->syncUserTier($user->id);
return response()->json(['ok' => true, 'revoked' => true]);
}
private function resolveUser(Request $request): ?User
{
if ($userId = $request->input('user_id')) {
return User::find((int) $userId);
}
if ($email = $request->input('email')) {
return User::where('email', (string) $email)->first();
}
return null;
}
private function parseDate(mixed $value): ?Carbon
{
if (!$value) {
return null;
}
try {
return Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
private function authorizeAdmin(Request $request): ?JsonResponse
{
$adminToken = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$q = trim((string) $request->query('q', ''));
$limit = max((int) $request->query('limit', 20), 1);
$query = User::query()->orderByDesc('id');
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
$items = $query->limit($limit)->get(['id', 'name', 'email', 'tier', 'created_at']);
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function show(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$email = trim((string) $request->query('email', ''));
$userId = (int) $request->query('user_id', 0);
$query = User::query();
if ($email !== '') {
$query->where('email', $email);
} elseif ($userId > 0) {
$query->where('id', $userId);
} else {
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
}
/** @var User|null $user */
$user = $query->first();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tier' => $user->tier,
'created_at' => $user->created_at,
],
]);
}
public function setTier(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$data = $request->validate([
'email' => 'nullable|email|max:255',
'user_id' => 'nullable|integer',
'tier' => 'required|string|in:free,personal',
]);
$query = User::query();
if (!empty($data['email'])) {
$query->where('email', $data['email']);
} elseif (!empty($data['user_id'])) {
$query->where('id', $data['user_id']);
} else {
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
}
/** @var User|null $user */
$user = $query->first();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$user->tier = $data['tier'];
$user->save();
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\WebhookEvent;
use App\Services\Billing\PaypalWebhookProcessor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminWebhookController extends Controller
{
public function __construct(private readonly PaypalWebhookProcessor $processor)
{
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
$provided = trim((string) $request->header('X-Admin-Token', ''));
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$query = WebhookEvent::query();
if ($provider = $request->query('provider')) {
$query->where('provider', (string) $provider);
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$limit = min(max((int) $request->query('limit', 50), 1), 200);
$items = $query->orderByDesc('id')->limit($limit)->get();
return response()->json(['ok' => true, 'items' => $items]);
}
public function show(Request $request, int $id): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$item = WebhookEvent::find($id);
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
return response()->json(['ok' => true, 'item' => $item]);
}
public function replay(Request $request, int $id): JsonResponse
{
if ($res = $this->authorizeAdmin($request)) {
return $res;
}
$item = WebhookEvent::find($id);
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$item->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
try {
if ($item->provider === 'paypal') {
$this->processor->process((string) ($item->event_type ?? ''), $item->payload ?? []);
}
$item->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
$item->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true, 'replayed' => true]);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api\V1;
use App\Services\Billing\LicenseVerificationService;
use App\Services\Auth\ApiKeyService;
use App\Services\System\SettingsService;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -21,7 +23,8 @@ class EmojiApiController extends Controller
private static ?array $dataset = null;
public function __construct(
private readonly LicenseVerificationService $verification
private readonly LicenseVerificationService $verification,
private readonly ApiKeyService $apiKeys
) {
}
@@ -41,6 +44,10 @@ class EmojiApiController extends Controller
public function categories(Request $request): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
try {
$data = $this->loadData();
@@ -87,6 +94,10 @@ class EmojiApiController extends Controller
public function emojis(Request $request): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
try {
$data = $this->loadData();
@@ -109,45 +120,7 @@ class EmojiApiController extends Controller
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
$filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$total = count($filtered);
$offset = ($page - 1) * $limit;
@@ -170,44 +143,123 @@ class EmojiApiController extends Controller
]);
}
if ($tier === self::TIER_FREE && $page === 1) {
$usage = $this->trackDailyUsage($request, $q, $category, $subSlug);
if ($usage['blocked']) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'daily_limit_reached',
'message' => 'Daily free limit reached. Upgrade to Pro for unlimited usage.',
'plan' => self::TIER_FREE,
'usage' => $usage['meta'],
], $tier, 429, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
$responsePayload['plan'] = self::TIER_FREE;
$responsePayload['usage'] = $usage['meta'];
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => (string) $usage['meta']['remaining'],
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
'ETag' => $etag,
'Cache-Control' => 'public, max-age=120',
]);
}
public function search(Request $request): JsonResponse
{
$private = filter_var($request->query('private', false), FILTER_VALIDATE_BOOL);
if (!$private) {
return $this->emojis($request);
}
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
if ((string) $user->tier !== 'personal') {
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
}
$q = trim((string) ($request->query('q', $request->query('query', ''))));
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
try {
$data = $this->loadData();
} catch (RuntimeException $e) {
return $this->jsonWithTier($request, [
'error' => 'data_load_failed',
'message' => $e->getMessage(),
], self::TIER_FREE, 500);
}
$items = $data['emojis'] ?? [];
$itemsBySlug = [];
foreach ($items as $item) {
$slug = (string) ($item['slug'] ?? '');
if ($slug !== '') {
$itemsBySlug[$slug] = $item;
}
}
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$publicBySlug = [];
foreach ($filtered as $item) {
$slug = (string) ($item['slug'] ?? '');
if ($slug !== '') {
$publicBySlug[$slug] = $item;
}
}
$privateMatches = [];
if ($q !== '') {
$rows = DB::table('user_keywords')
->where('user_id', $user->id)
->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%'])
->get(['id', 'emoji_slug', 'keyword', 'lang']);
foreach ($rows as $row) {
$slug = (string) $row->emoji_slug;
if ($slug === '') {
continue;
}
$privateMatches[$slug] = [
'id' => (int) $row->id,
'keyword' => (string) $row->keyword,
'lang' => (string) $row->lang,
];
}
}
$tier = self::TIER_PRO;
$merged = [];
foreach ($privateMatches as $slug => $meta) {
$sourceItem = $publicBySlug[$slug] ?? $itemsBySlug[$slug] ?? null;
if ($sourceItem === null) {
continue;
}
$item = $this->transformItem($sourceItem, $tier);
$item['source'] = 'private';
$item['matched_keyword_id'] = $meta['id'] ?? null;
$item['matched_keyword'] = $meta['keyword'];
$item['matched_lang'] = $meta['lang'];
$merged[$slug] = $item;
}
foreach ($publicBySlug as $slug => $item) {
if (isset($merged[$slug])) {
continue;
}
$row = $this->transformItem($item, $tier);
$row['source'] = 'public';
$merged[$slug] = $row;
}
$mergedItems = array_values($merged);
$page = max((int) $request->query('page', 1), 1);
$limit = max((int) $request->query('limit', 20), 1);
$total = count($mergedItems);
$pageItems = array_slice($mergedItems, ($page - 1) * $limit, $limit);
return response()->json([
'ok' => true,
'items' => $pageItems,
'total' => $total,
'page' => $page,
'limit' => $limit,
'plan' => 'personal',
]);
}
public function emoji(Request $request, ?string $slug = null): JsonResponse
{
if ($blocked = $this->enforcePublicAccess($request)) {
return $blocked;
}
$tier = $this->detectTier($request);
$slug = trim((string) ($slug ?? $request->query('slug', '')));
if ($slug === '') {
@@ -243,6 +295,19 @@ class EmojiApiController extends Controller
}
$payload = $this->transformEmojiDetail($match, $tier);
$includeUserKeywords = filter_var($request->query('include_user_keywords', false), FILTER_VALIDATE_BOOL);
if ($includeUserKeywords) {
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
if ($user && (string) $user->tier === 'personal') {
$payload['user_keywords'] = DB::table('user_keywords')
->where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get(['id', 'keyword', 'lang']);
} else {
$payload['user_keywords'] = [];
}
}
$etag = '"'.sha1(json_encode([$payload, $tier])).'"';
if ($this->isNotModified($request, $etag)) {
return $this->jsonWithTier($request, [], $tier, 304, [
@@ -277,6 +342,148 @@ class EmojiApiController extends Controller
return self::TIER_FREE;
}
private function enforcePublicAccess(Request $request): ?JsonResponse
{
if ($this->hasApiKeyAuth($request)) {
return null;
}
$config = config('dewemoji.public_access', []);
$settings = app(SettingsService::class);
$maintenanceEnabled = (bool) $settings->get('maintenance_enabled', false);
if ($maintenanceEnabled) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'maintenance',
], self::TIER_FREE, 503);
}
$enforceWhitelist = (bool) $settings->get('public_enforce', $config['enforce_whitelist'] ?? false);
$allowedOrigins = $settings->get('public_origins', $config['allowed_origins'] ?? []);
$extensionIds = $settings->get('public_extension_ids', $config['extension_ids'] ?? []);
$limitOverride = $settings->get('public_hourly_limit', $config['hourly_limit'] ?? 0);
$allowed = $this->isPublicAllowed($request, [
'allowed_origins' => $allowedOrigins,
'extension_ids' => $extensionIds,
]);
if ($enforceWhitelist && !$allowed) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'public_access_denied',
], self::TIER_FREE, 403);
}
if (!$allowed) {
$limit = max((int) $limitOverride, 0);
if ($limit > 0) {
$usage = $this->trackPublicHourlyUsage($request, $limit);
if ($usage['blocked']) {
return $this->jsonWithTier($request, [
'ok' => false,
'error' => 'public_rate_limited',
'usage' => $usage['meta'],
], self::TIER_FREE, 429, [
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) $usage['meta']['window_ends_at_unix'],
]);
}
}
}
return null;
}
/**
* @param array<string,mixed> $config
*/
private function isPublicAllowed(Request $request, array $config): bool
{
if (app()->environment(['local', 'testing'])) {
return true;
}
$host = trim((string) $request->getHost());
if (in_array($host, ['127.0.0.1', 'localhost'], true)) {
return true;
}
$origin = trim((string) $request->headers->get('Origin', ''));
$allowedOrigins = $config['allowed_origins'] ?? [];
if ($origin !== '' && is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
return true;
}
$frontendHeader = trim((string) $request->header('X-Dewemoji-Frontend', ''));
$extensionIds = $config['extension_ids'] ?? [];
if ($frontendHeader !== '' && is_array($extensionIds)) {
foreach ($extensionIds as $id) {
if ($id !== '' && str_contains($frontendHeader, $id)) {
return true;
}
}
}
$extensionHeader = trim((string) $request->header('X-Extension-Id', ''));
if ($extensionHeader !== '' && is_array($extensionIds)) {
if (in_array($extensionHeader, $extensionIds, true)) {
return true;
}
}
$userAgent = (string) $request->userAgent();
if ($userAgent !== '' && is_array($extensionIds)) {
foreach ($extensionIds as $id) {
if ($id !== '' && str_contains($userAgent, $id)) {
return true;
}
}
}
return false;
}
private function hasApiKeyAuth(Request $request): bool
{
return $this->apiKeys->resolveUser($request) !== null;
}
/**
* @return array{blocked:bool,meta:array<string,mixed>}
*/
private function trackPublicHourlyUsage(Request $request, int $limit): array
{
$bucket = sha1((string) $request->ip().'|'.(string) $request->userAgent());
$hourKey = Carbon::now('UTC')->format('YmdH');
$cacheKey = 'dw_public_hourly_'.$bucket.'_'.$hourKey;
$seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::now('UTC')->addHour()->startOfHour()));
$current = Cache::get($cacheKey, 0);
if (!is_int($current)) {
$current = (int) $current;
}
$current++;
Cache::put($cacheKey, $current, $seconds);
$blocked = $current > $limit;
$windowEnds = Carbon::now('UTC')->addHour()->startOfHour();
return [
'blocked' => $blocked,
'meta' => [
'used' => min($current, $limit),
'limit' => $limit,
'remaining' => max(0, $limit - $current),
'window' => 'hourly',
'window_ends_at' => $windowEnds->toIso8601String(),
'window_ends_at_unix' => $windowEnds->timestamp,
],
];
}
private function isNotModified(Request $request, string $etag): bool
{
$ifNoneMatch = trim((string) $request->header('If-None-Match', ''));
@@ -329,6 +536,53 @@ class EmojiApiController extends Controller
return trim($value, '-');
}
/**
* @param array<int,array<string,mixed>> $items
* @return array<int,array<string,mixed>>
*/
private function filterItems(array $items, string $q, string $category, string $subSlug): array
{
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\Extension\ExtensionVerificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class ExtensionController extends Controller
{
/** @var array<string,mixed>|null */
private static ?array $dataset = null;
public function __construct(private readonly ExtensionVerificationService $verifier)
{
}
public function verify(Request $request): JsonResponse
{
$token = trim((string) $request->header('X-Extension-Token', ''));
$expected = config('dewemoji.public_access.extension_ids', []);
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
return response()->json([
'ok' => $ok,
'verified' => $ok,
], $ok ? 200 : 403);
}
public function search(Request $request): JsonResponse
{
$token = trim((string) $request->header('X-Extension-Token', ''));
$expected = config('dewemoji.public_access.extension_ids', []);
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
if (!$ok) {
return response()->json(['ok' => false, 'error' => 'extension_unverified'], 403);
}
try {
$data = $this->loadData();
} catch (RuntimeException $e) {
return response()->json([
'ok' => false,
'error' => 'data_load_failed',
'message' => $e->getMessage(),
], 500);
}
$items = $data['emojis'] ?? [];
$q = trim((string) ($request->query('q', $request->query('query', ''))));
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
$page = max((int) $request->query('page', 1), 1);
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
$maxLimit = max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
$filtered = $this->filterItems($items, $q, $category, $subSlug);
$total = count($filtered);
$offset = ($page - 1) * $limit;
$pageItems = array_slice($filtered, $offset, $limit);
return response()->json([
'ok' => true,
'items' => array_map(fn (array $item): array => $this->transformItem($item), $pageItems),
'total' => $total,
'page' => $page,
'limit' => $limit,
'plan' => 'free',
]);
}
/**
* @return array<string,mixed>
*/
private function loadData(): array
{
if (self::$dataset !== null) {
return self::$dataset;
}
$path = (string) config('dewemoji.data_path');
if (!is_file($path)) {
throw new RuntimeException('Emoji dataset file was not found at: '.$path);
}
$raw = file_get_contents($path);
if ($raw === false) {
throw new RuntimeException('Emoji dataset file could not be read.');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Emoji dataset JSON is invalid.');
}
self::$dataset = $decoded;
return self::$dataset;
}
private function normalizeCategoryFilter(string $category): string
{
$value = strtolower(trim($category));
if ($value === '' || $value === 'all') {
return '';
}
$map = [
'all' => 'all',
'smileys' => 'Smileys & Emotion',
'people' => 'People & Body',
'animals' => 'Animals & Nature',
'food' => 'Food & Drink',
'travel' => 'Travel & Places',
'activities' => 'Activities',
'objects' => 'Objects',
'symbols' => 'Symbols',
'flags' => 'Flags',
];
return $map[$value] ?? $category;
}
private function slugify(string $text): string
{
$value = strtolower(trim($text));
$value = str_replace('&', 'and', $value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
return trim($value, '-');
}
/**
* @param array<int,array<string,mixed>> $items
* @return array<int,array<string,mixed>>
*/
private function filterItems(array $items, string $q, string $category, string $subSlug): array
{
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
$itemCategory = trim((string) ($item['category'] ?? ''));
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
return false;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
return false;
}
if ($q === '') {
return true;
}
$haystack = strtolower(implode(' ', [
(string) ($item['emoji'] ?? ''),
(string) ($item['name'] ?? ''),
(string) ($item['slug'] ?? ''),
$itemCategory,
$itemSubcategory,
implode(' ', $item['keywords_en'] ?? []),
implode(' ', $item['keywords_id'] ?? []),
implode(' ', $item['aliases'] ?? []),
implode(' ', $item['shortcodes'] ?? []),
implode(' ', $item['alt_shortcodes'] ?? []),
implode(' ', $item['intent_tags'] ?? []),
]));
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if (!str_contains($haystack, $token)) {
return false;
}
}
return true;
}));
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
private function transformItem(array $item): array
{
return [
'emoji' => (string) ($item['emoji'] ?? ''),
'name' => (string) ($item['name'] ?? ''),
'slug' => (string) ($item['slug'] ?? ''),
'category' => (string) ($item['category'] ?? ''),
'subcategory' => (string) ($item['subcategory'] ?? ''),
'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false),
'summary' => $this->summary((string) ($item['description'] ?? ''), 150),
];
}
private function summary(string $text, int $max): string
{
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
if (mb_strlen($text) <= $max) {
return $text;
}
return rtrim(mb_substr($text, 0, $max - 1), " ,.;:-").'…';
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\WebhookEvent;
use App\Services\Billing\PaypalWebhookProcessor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaypalWebhookController extends Controller
{
public function __construct(private readonly PaypalWebhookProcessor $processor)
{
}
public function handle(Request $request): JsonResponse
{
$payload = $request->all();
$eventType = (string) ($payload['event_type'] ?? '');
$eventId = (string) ($payload['id'] ?? '');
$signatureOk = $this->verifySignature($request);
if ($eventId !== '' && WebhookEvent::where('provider', 'paypal')->where('event_id', $eventId)->exists()) {
return response()->json(['ok' => true, 'duplicate' => true]);
}
$event = WebhookEvent::create([
'provider' => 'paypal',
'event_id' => $eventId !== '' ? $eventId : null,
'event_type' => $eventType !== '' ? $eventType : null,
'status' => $signatureOk ? 'received' : 'error',
'payload' => $payload,
'headers' => $this->extractHeaders($request),
'received_at' => now(),
'error' => $signatureOk ? null : 'signature_unverified',
]);
if (!$signatureOk) {
return response()->json(['ok' => true, 'signature' => 'unverified']);
}
try {
$this->processor->process($eventType, $payload);
$event->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
$event->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function verifySignature(Request $request): bool
{
// TODO: Implement PayPal signature verification using transmission headers + webhook ID.
// For now this returns true to allow local testing.
return true;
}
/**
* @return array<string,string>
*/
private function extractHeaders(Request $request): array
{
$headers = [
'paypal-transmission-id' => (string) $request->header('PayPal-Transmission-Id', ''),
'paypal-transmission-sig' => (string) $request->header('PayPal-Transmission-Sig', ''),
'paypal-cert-url' => (string) $request->header('PayPal-Cert-Url', ''),
'paypal-auth-algo' => (string) $request->header('PayPal-Auth-Algo', ''),
'paypal-transmission-time' => (string) $request->header('PayPal-Transmission-Time', ''),
];
return array_filter($headers, fn (string $value): bool => $value !== '');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use Illuminate\Http\JsonResponse;
class PricingController extends Controller
{
public function index(): JsonResponse
{
$plans = PricingPlan::where('status', 'active')
->orderBy('id')
->get(['code', 'name', 'currency', 'amount', 'period', 'meta']);
return response()->json([
'ok' => true,
'plans' => $plans,
]);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\UserApiKey;
use App\Services\Auth\ApiKeyService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|min:8|max:255',
'name' => 'nullable|string|max:120',
]);
$name = $data['name'] ?? strtok($data['email'], '@');
$user = User::create([
'name' => $name ?: 'User',
'email' => $data['email'],
'password' => Hash::make($data['password']),
'tier' => 'free',
]);
$issued = null;
if ((string) $user->tier === 'personal') {
$issued = $this->keys->issueKey($user, 'default');
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
'api_key' => $issued['plain'] ?? null,
]);
}
public function login(Request $request): JsonResponse
{
$data = $request->validate([
'email' => 'required|email|max:255',
'password' => 'required|string|max:255',
]);
/** @var User|null $user */
$user = User::where('email', $data['email'])->first();
if (!$user || !Hash::check($data['password'], $user->password)) {
return response()->json([
'ok' => false,
'error' => 'invalid_credentials',
], 401);
}
$issued = null;
if ((string) $user->tier === 'personal') {
$issued = $this->keys->issueKey($user, 'login');
}
return response()->json([
'ok' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
'tier' => $user->tier,
],
'api_key' => $issued['plain'] ?? null,
]);
}
public function logout(Request $request): JsonResponse
{
$key = $this->keys->parseKey($request);
if ($key === '') {
return response()->json(['ok' => true]);
}
$hash = $this->keys->hashKey($key);
UserApiKey::where('key_hash', $hash)->update([
'revoked_at' => Carbon::now(),
]);
return response()->json(['ok' => true]);
}
public function listApiKeys(Request $request): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$items = UserApiKey::where('user_id', $user->id)
->orderByDesc('id')
->get()
->map(fn (UserApiKey $key) => [
'id' => $key->id,
'prefix' => $key->key_prefix,
'name' => $key->name,
'created_at' => $key->created_at,
'last_used_at' => $key->last_used_at,
'revoked_at' => $key->revoked_at,
]);
return response()->json([
'ok' => true,
'items' => $items,
]);
}
public function createApiKey(Request $request): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
if ((string) $user->tier !== 'personal') {
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
}
$data = $request->validate([
'name' => 'nullable|string|max:100',
]);
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
return response()->json([
'ok' => true,
'api_key' => $issued['plain'],
'key' => [
'id' => $issued['record']->id,
'prefix' => $issued['record']->key_prefix,
'name' => $issued['record']->name,
'created_at' => $issued['record']->created_at,
],
]);
}
public function revokeApiKey(Request $request, string $key): JsonResponse
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$hash = $this->keys->hashKey($key);
$updated = UserApiKey::where('user_id', $user->id)
->where('key_hash', $hash)
->whereNull('revoked_at')
->update(['revoked_at' => Carbon::now()]);
return response()->json([
'ok' => true,
'revoked' => $updated > 0,
]);
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\UserKeyword;
use App\Services\Auth\ApiKeyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserKeywordController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
private function ensureUser(Request $request): ?array
{
$user = $this->keys->resolveUser($request);
if (!$user) {
return ['error' => 'unauthorized', 'status' => 401];
}
return ['user' => $user];
}
public function index(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$user = $check['user'];
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['id', 'emoji_slug', 'keyword', 'lang', 'created_at', 'updated_at']);
return response()->json(['ok' => true, 'items' => $items]);
}
public function store(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$lang = $data['lang'] ?? 'und';
$user = $check['user'];
$limit = $this->keywordLimitFor($user);
if ($limit !== null) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
}
}
}
$item = UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $lang,
]
);
return response()->json(['ok' => true, 'item' => $item]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$deleted = UserKeyword::where('user_id', $check['user']->id)
->where('id', $id)
->delete();
return response()->json(['ok' => true, 'deleted' => $deleted > 0]);
}
public function update(Request $request, int $id): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$item = UserKeyword::where('user_id', $check['user']->id)
->where('id', $id)
->first();
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
$duplicate = UserKeyword::where('user_id', $check['user']->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
if ($duplicate && $duplicate->id !== $item->id) {
$item->delete();
$item = $duplicate;
} else {
$item->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
}
return response()->json(['ok' => true, 'item' => $item]);
}
public function export(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$items = UserKeyword::where('user_id', $check['user']->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang']);
return response()->json(['ok' => true, 'items' => $items]);
}
public function import(Request $request): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'items' => 'required|array',
'items.*.emoji_slug' => 'required|string|max:120',
'items.*.keyword' => 'required|string|max:200',
'items.*.lang' => 'nullable|string|max:10',
]);
$count = 0;
$skipped = 0;
$user = $check['user'];
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
foreach ($data['items'] as $row) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $row['emoji_slug'])
->where('keyword', $row['keyword'])
->exists();
if (!$exists && $limit !== null && $current >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $row['emoji_slug'],
'keyword' => $row['keyword'],
],
[
'lang' => $row['lang'] ?? 'und',
]
);
if (!$exists) {
$current += 1;
}
$count += 1;
}
return response()->json(['ok' => true, 'imported' => $count, 'skipped' => $skipped]);
}
private function keywordLimitFor($user): ?int
{
if ((string) ($user->tier ?? 'free') === 'personal') {
return null;
}
return (int) config('dewemoji.pagination.free_max_limit', 20);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard.overview', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard.overview', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard.overview', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard.overview', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
$user->sendEmailVerificationNotification();
}
Auth::login($user);
return redirect(route('dashboard.overview', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PakasirController extends Controller
{
public function createTransaction(Request $request): JsonResponse
{
$data = $request->validate([
'plan_code' => 'required|string|in:personal_monthly,personal_annual,personal_lifetime',
]);
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$config = config('dewemoji.billing.providers.pakasir', []);
$enabled = (bool) ($config['enabled'] ?? false);
$apiBase = rtrim((string) ($config['api_base'] ?? ''), '/');
$apiKey = (string) ($config['api_key'] ?? '');
$project = (string) ($config['project'] ?? '');
$timeout = (int) ($config['timeout'] ?? 10);
if (!$enabled || $apiBase === '' || $apiKey === '' || $project === '') {
return response()->json(['error' => 'pakasir_not_configured'], 422);
}
$amountIdr = $this->resolvePlanAmountIdr($data['plan_code']);
if ($amountIdr <= 0) {
return response()->json(['error' => 'invalid_plan_amount'], 422);
}
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$order = Order::create([
'user_id' => $user->id,
'plan_code' => $data['plan_code'],
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
'currency' => 'IDR',
'amount' => $amountIdr,
'status' => 'pending',
'provider' => 'pakasir',
]);
$orderRef = 'DW-'.$order->id.'-'.now()->format('ymdHis');
$payload = [
'project' => $project,
'order_id' => $orderRef,
'amount' => $amountIdr,
'api_key' => $apiKey,
'description' => 'Dewemoji '.$data['plan_code'].' for '.$user->email,
];
$endpoint = $apiBase.'/api/transactioncreate/qris';
$res = Http::timeout($timeout)->post($endpoint, $payload);
if (!$res->ok()) {
Log::warning('Pakasir create transaction failed', ['body' => $res->body()]);
return response()->json(['error' => 'pakasir_create_failed'], 502);
}
$body = $res->json();
$payment = is_array($body['payment'] ?? null) ? $body['payment'] : $body;
$paymentNumber = (string) ($payment['payment_number'] ?? '');
$status = (string) ($payment['status'] ?? 'pending');
$expiredAt = (string) ($payment['expired_at'] ?? '');
$totalPayment = (int) ($payment['total_payment'] ?? $amountIdr);
$order->update([
'provider_ref' => $orderRef,
'status' => $status === 'success' ? 'pending' : 'pending',
]);
Payment::create([
'user_id' => $user->id,
'order_id' => $order->id,
'provider' => 'pakasir',
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
'plan_code' => $data['plan_code'],
'currency' => 'IDR',
'amount' => $amountIdr,
'status' => 'pending',
'provider_ref' => $paymentNumber !== '' ? $paymentNumber : $orderRef,
'raw_payload' => $body,
]);
return response()->json([
'ok' => true,
'order_id' => $orderRef,
'payment_number' => $paymentNumber,
'amount' => $amountIdr,
'total_payment' => $totalPayment,
'expired_at' => $expiredAt,
'status' => $status,
]);
}
public function cancelPending(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$orderRef = (string) $request->input('order_id', '');
$orderQuery = Order::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending');
if ($orderRef !== '') {
$orderQuery->where('provider_ref', $orderRef);
}
$order = $orderQuery->orderByDesc('id')->first();
if (!$order) {
return response()->json(['ok' => true, 'cancelled' => false]);
}
$order->update(['status' => 'cancelled']);
Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'cancelled']);
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
return response()->json(['ok' => true, 'cancelled' => true]);
}
public function webhook(Request $request): JsonResponse
{
$payload = $request->all();
$eventId = (string) ($payload['id'] ?? $payload['event_id'] ?? Str::uuid());
$eventType = (string) ($payload['event_type'] ?? $payload['status'] ?? 'event');
WebhookEvent::create([
'provider' => 'pakasir',
'event_id' => $eventId,
'event_type' => $eventType,
'status' => 'received',
'payload' => $payload,
'headers' => $request->headers->all(),
'received_at' => now(),
]);
$this->handlePakasirPayload($payload);
return response()->json(['ok' => true]);
}
private function handlePakasirPayload(array $payload): void
{
$status = strtolower((string) ($payload['status'] ?? ''));
if (!in_array($status, ['paid', 'success', 'settlement', 'completed'], true)) {
return;
}
$orderId = (string) ($payload['order_id'] ?? '');
if ($orderId === '') {
return;
}
$order = Order::where('provider', 'pakasir')->where('provider_ref', $orderId)->first();
if (!$order) {
return;
}
$order->update(['status' => 'paid']);
Payment::where('order_id', $order->id)->update(['status' => 'paid']);
$user = User::find($order->user_id);
if (!$user) {
return;
}
$expiresAt = null;
if ($order->plan_code === 'personal_monthly') {
$expiresAt = now()->addMonth();
} elseif ($order->plan_code === 'personal_annual') {
$expiresAt = now()->addYear();
}
Subscription::updateOrCreate(
[
'provider' => 'pakasir',
'provider_ref' => $orderId,
],
[
'user_id' => $user->id,
'plan' => $order->plan_code,
'status' => 'active',
'started_at' => now(),
'expires_at' => $expiresAt,
]
);
User::where('id', $user->id)->update(['tier' => 'personal']);
}
private function resolvePlanAmountIdr(string $planCode): int
{
$plan = PricingPlan::where('code', $planCode)->where('status', 'active')->first();
if ($plan) {
return (int) $plan->amount;
}
$defaults = collect(config('dewemoji.pricing.defaults', []))->keyBy('code');
$fallback = $defaults->get($planCode);
if (!$fallback) {
return 0;
}
return (int) ($fallback['amount'] ?? 0);
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use App\Services\System\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class PayPalController extends Controller
{
public function createSubscription(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate([
'plan_code' => 'required|string|in:personal_monthly,personal_annual',
]);
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'auth_required'], 401);
}
$mode = $this->billingMode();
if (!$this->paypalConfigured($mode)) {
return response()->json(['error' => 'paypal_not_configured'], 422);
}
$planId = $this->resolvePlanId($data['plan_code'], $mode);
if (!$planId) {
return response()->json(['error' => 'paypal_plan_missing'], 422);
}
$token = $this->getAccessToken($mode);
if (!$token) {
return response()->json(['error' => 'paypal_auth_failed'], 502);
}
$appUrl = rtrim(config('app.url'), '/');
$payload = [
'plan_id' => $planId,
'subscriber' => [
'name' => [
'given_name' => $user->name ?? 'User',
'surname' => 'Dewemoji',
],
'email_address' => $user->email,
],
'application_context' => [
'brand_name' => 'Dewemoji',
'locale' => 'en-US',
'user_action' => 'SUBSCRIBE_NOW',
'return_url' => $appUrl.'/billing/paypal/return?status=success',
'cancel_url' => $appUrl.'/billing/paypal/return?status=cancel',
],
];
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/subscriptions', $payload);
$body = $res->json();
$subscriptionId = $body['id'] ?? null;
$approveUrl = collect($body['links'] ?? [])->firstWhere('rel', 'approve')['href'] ?? null;
if (!$subscriptionId || !$approveUrl) {
if (!$res->ok()) {
Log::warning('PayPal create subscription failed', [
'status' => $res->status(),
'body' => $res->body(),
]);
} else {
Log::warning('PayPal create subscription missing approve link', [
'status' => $res->status(),
'body' => $res->body(),
]);
}
return response()->json(['error' => 'paypal_invalid_response'], 502);
}
$amountUsd = $this->resolvePlanAmountUsd($data['plan_code']);
Subscription::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$order = Order::create([
'user_id' => $user->id,
'plan_code' => $data['plan_code'],
'type' => 'subscription',
'currency' => 'USD',
'amount' => $amountUsd,
'status' => 'pending',
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
]);
Payment::create([
'user_id' => $user->id,
'order_id' => $order->id,
'provider' => 'paypal',
'type' => 'subscription',
'plan_code' => $data['plan_code'],
'currency' => 'USD',
'amount' => $amountUsd,
'status' => 'pending',
'provider_ref' => $subscriptionId,
'raw_payload' => $body,
]);
Subscription::firstOrCreate([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
], [
'user_id' => $user->id,
'plan' => $data['plan_code'],
'status' => 'pending',
'started_at' => now(),
]);
return response()->json(['approve_url' => $approveUrl]);
}
public function return(Request $request): RedirectResponse
{
$status = (string) $request->query('status', 'success');
return redirect()->route('dashboard.billing', ['status' => $status]);
}
public function webhook(Request $request): JsonResponse
{
$payload = $request->all();
$eventId = (string) ($payload['id'] ?? '');
$eventType = (string) ($payload['event_type'] ?? '');
$mode = config('dewemoji.billing.mode', 'sandbox');
$webhookId = config("dewemoji.billing.providers.paypal.webhook_ids.{$mode}");
if ($webhookId) {
$verified = $this->verifySignature($mode, $webhookId, $payload, $request);
if (!$verified) {
return response()->json(['error' => 'invalid_signature'], 401);
}
}
$event = WebhookEvent::create([
'provider' => 'paypal',
'event_id' => $eventId ?: (string) Str::uuid(),
'event_type' => $eventType,
'status' => 'received',
'payload' => $payload,
'headers' => $request->headers->all(),
'received_at' => now(),
]);
try {
$processed = $this->processPayPalEvent($payload);
$event->update([
'status' => $processed ? 'processed' : 'received',
'processed_at' => $processed ? now() : null,
]);
} catch (\Throwable $e) {
$event->update([
'status' => 'error',
'error' => $e->getMessage(),
'processed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function processPayPalEvent(array $payload): bool
{
$type = (string) ($payload['event_type'] ?? '');
$resource = $payload['resource'] ?? [];
$subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? '');
if ($subscriptionId === '') {
return false;
}
if ($type === 'BILLING.SUBSCRIPTION.ACTIVATED') {
$sub = Subscription::firstOrNew([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
]);
if (!$sub->user_id) {
$order = Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
if ($order) {
$sub->user_id = $order->user_id;
}
}
$sub->status = 'active';
$sub->started_at = $sub->started_at ?? now();
$sub->next_renewal_at = $resource['billing_info']['next_billing_time'] ?? null;
$sub->save();
if ($sub->user_id) {
User::where('id', $sub->user_id)->update(['tier' => 'personal']);
}
Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
Payment::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
return true;
}
if (in_array($type, ['BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) {
$sub = Subscription::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
if ($sub) {
$sub->status = 'canceled';
$sub->canceled_at = now();
$sub->save();
}
return true;
}
return false;
}
private function resolvePlanId(string $planCode, string $mode): ?string
{
$plan = PricingPlan::where('code', $planCode)->first();
if ($plan) {
$meta = $plan->meta ?? [];
$stored = $meta['paypal'][$mode]['plan']['id'] ?? null;
if ($stored) {
return $stored;
}
}
return config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$planCode}") ?: null;
}
private function resolvePlanAmountUsd(string $planCode): int
{
$plan = PricingPlan::where('code', $planCode)->first();
if (!$plan) {
return 0;
}
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
if ($rate <= 0) {
return 0;
}
return (int) round($plan->amount / $rate);
}
private function getAccessToken(string $mode): ?string
{
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if (!$clientId || !$clientSecret || !$apiBase) {
return null;
}
$res = Http::asForm()
->withBasicAuth($clientId, $clientSecret)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (!$res->ok()) {
return null;
}
return $res->json('access_token');
}
private function paypalConfigured(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
return $enabled && $clientId && $clientSecret && $apiBase;
}
private function billingMode(): string
{
$settings = app(SettingsService::class);
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
}
private function verifySignature(string $mode, string $webhookId, array $payload, Request $request): bool
{
$token = $this->getAccessToken($mode);
if (!$token) {
return false;
}
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$verifyPayload = [
'auth_algo' => $request->header('paypal-auth-algo'),
'cert_url' => $request->header('paypal-cert-url'),
'transmission_id' => $request->header('paypal-transmission-id'),
'transmission_sig' => $request->header('paypal-transmission-sig'),
'transmission_time' => $request->header('paypal-transmission-time'),
'webhook_id' => $webhookId,
'webhook_event' => $payload,
];
if (collect($verifyPayload)->contains(fn ($v) => empty($v))) {
return false;
}
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/notifications/verify-webhook-signature', $verifyPayload);
return $res->ok() && $res->json('verification_status') === 'SUCCESS';
}
}

View File

@@ -0,0 +1,688 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\PricingChange;
use App\Models\PricingPlan;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Models\AdminAuditLog;
use App\Services\Billing\PayPalPlanSyncService;
use App\Services\System\SettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminDashboardController extends Controller
{
public function __construct(private readonly SettingsService $settings)
{
}
public function users(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$tier = trim((string) $request->query('tier', ''));
$role = trim((string) $request->query('role', ''));
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = User::query();
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
if ($tier !== '') {
$query->where('tier', $tier);
}
if ($role !== '') {
$query->where('role', $role);
}
$users = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
return view('dashboard.admin.users', [
'users' => $users,
'filters' => ['q' => $q, 'tier' => $tier, 'role' => $role],
'sort' => $sort,
'dir' => $dir,
]);
}
public function userDetail(User $user): View
{
$subscriptions = Subscription::where('user_id', $user->id)->orderByDesc('id')->get();
return view('dashboard.admin.user-show', [
'user' => $user,
'subscriptions' => $subscriptions,
]);
}
public function updateUserTier(Request $request): RedirectResponse
{
$data = $request->validate([
'user_id' => 'required|integer',
'tier' => 'required|string|in:free,personal',
]);
User::where('id', $data['user_id'])->update(['tier' => $data['tier']]);
if ($data['tier'] === 'free') {
UserApiKey::where('user_id', $data['user_id'])->update(['revoked_at' => now()]);
}
$this->logAdminAction('user_tier_update', $data);
return back()->with('status', 'User tier updated.');
}
public function createUser(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'nullable|string|max:120',
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|string|min:8|max:255',
'role' => 'required|string|in:admin,user',
'tier' => 'required|string|in:free,personal',
]);
$name = $data['name'] ?: strtok($data['email'], '@');
$user = User::create([
'name' => $name ?: 'User',
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => $data['role'],
'tier' => $data['tier'],
]);
$this->logAdminAction('user_created', [
'user_id' => $user->id,
'email' => $user->email,
'role' => $user->role,
'tier' => $user->tier,
]);
return back()->with('status', 'User created.');
}
public function deleteUser(Request $request, User $user): RedirectResponse
{
$admin = $request->user();
if ($admin && $admin->id === $user->id) {
return back()->withErrors(['user' => 'You cannot delete your own account.']);
}
if (($user->role ?? 'user') === 'admin' && User::where('role', 'admin')->count() <= 1) {
return back()->withErrors(['user' => 'Cannot delete the last admin account.']);
}
DB::transaction(function () use ($user): void {
Payment::where('user_id', $user->id)->delete();
Order::where('user_id', $user->id)->delete();
Subscription::where('user_id', $user->id)->delete();
UserApiKey::where('user_id', $user->id)->delete();
UserKeyword::where('user_id', $user->id)->delete();
DB::table('password_reset_tokens')->where('email', $user->email)->delete();
$user->delete();
});
$this->logAdminAction('user_deleted', [
'user_id' => $user->id,
'email' => $user->email,
]);
return back()->with('status', 'User deleted.');
}
public function subscriptions(Request $request): View
{
$query = Subscription::query()->with('user:id,email,name,tier');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
if ($userId = $request->query('user_id')) {
$query->where('user_id', (int) $userId);
}
if ($email = $request->query('email')) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$subscriptions = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
return view('dashboard.admin.subscriptions', [
'subscriptions' => $subscriptions,
'filters' => [
'user_id' => $request->query('user_id'),
'email' => $request->query('email'),
'status' => $request->query('status'),
],
'sort' => $sort,
'dir' => $dir,
]);
}
public function subscriptionDetail(Subscription $subscription): View
{
$subscription->loadMissing('user:id,name,email,tier,role');
return view('dashboard.admin.subscription-show', [
'subscription' => $subscription,
]);
}
public function grantSubscription(Request $request): RedirectResponse
{
$data = $request->validate([
'user_id' => 'nullable|integer',
'email' => 'nullable|email|max:255',
'plan' => 'required|string|max:20',
'status' => 'required|string|in:active,pending,revoked',
'provider' => 'nullable|string|max:20',
'provider_ref' => 'nullable|string|max:100',
'started_at' => 'nullable|date',
'expires_at' => 'nullable|date',
]);
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
if (!$user) {
return back()->withErrors(['user' => 'User not found.']);
}
$startedAt = $data['started_at'] ? Carbon::parse($data['started_at']) : now();
$expiresAt = $data['expires_at'] ? Carbon::parse($data['expires_at']) : null;
Subscription::create([
'user_id' => $user->id,
'plan' => $data['plan'],
'status' => $data['status'],
'provider' => $data['provider'] ?? 'admin',
'provider_ref' => $data['provider_ref'] ?? null,
'started_at' => $startedAt,
'expires_at' => $expiresAt,
]);
if ($data['status'] === 'active') {
$user->update(['tier' => 'personal']);
}
$this->logAdminAction('subscription_grant', [
'user_id' => $user->id,
'email' => $user->email,
'plan' => $data['plan'],
'status' => $data['status'],
]);
return back()->with('status', 'Subscription granted.');
}
public function revokeSubscription(Request $request): RedirectResponse
{
$data = $request->validate([
'subscription_id' => 'nullable|integer',
'user_id' => 'nullable|integer',
'email' => 'nullable|email|max:255',
]);
if (!empty($data['subscription_id'])) {
$sub = Subscription::find($data['subscription_id']);
if (!$sub) {
return back()->withErrors(['subscription' => 'Subscription not found.']);
}
$sub->update(['status' => 'revoked', 'expires_at' => now()]);
$this->syncUserTier($sub->user_id);
$this->logAdminAction('subscription_revoke', [
'subscription_id' => $sub->id,
'user_id' => $sub->user_id,
]);
return back()->with('status', 'Subscription revoked.');
}
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
if (!$user) {
return back()->withErrors(['user' => 'User not found.']);
}
Subscription::where('user_id', $user->id)
->where('status', 'active')
->update(['status' => 'revoked', 'expires_at' => now()]);
$this->syncUserTier($user->id);
$this->logAdminAction('subscription_revoke_all', [
'user_id' => $user->id,
'email' => $user->email,
]);
return back()->with('status', 'User subscriptions revoked.');
}
public function pricing(): View
{
$plans = PricingPlan::orderBy('id')->get();
$changes = PricingChange::orderByDesc('id')->limit(5)->get();
return view('dashboard.admin.pricing', [
'plans' => $plans,
'changes' => $changes,
]);
}
public function updatePricing(Request $request): RedirectResponse
{
$data = $request->validate([
'plans' => 'required|array|min:1',
'plans.*.code' => 'required|string|max:30',
'plans.*.name' => 'required|string|max:50',
'plans.*.amount_idr' => 'required|integer|min:0',
'plans.*.amount_usd' => 'nullable|numeric|min:0',
'plans.*.period' => 'nullable|string|max:20',
'plans.*.status' => 'nullable|string|max:20',
]);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($data): void {
foreach ($data['plans'] as $plan) {
$meta = [
'prices' => [
'IDR' => (int) $plan['amount_idr'],
'USD' => isset($plan['amount_usd']) ? (float) $plan['amount_usd'] : null,
],
];
PricingPlan::updateOrCreate(
['code' => $plan['code']],
[
'name' => $plan['name'],
'currency' => 'IDR',
'amount' => (int) $plan['amount_idr'],
'period' => $plan['period'] ?? null,
'status' => $plan['status'] ?? 'active',
'meta' => $meta,
]
);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $after,
]);
$this->logAdminAction('pricing_update', ['plans' => count($data['plans'])]);
return back()->with('status', 'Pricing updated.');
}
public function syncPaypalPlans(PayPalPlanSyncService $sync): RedirectResponse
{
$resultSandbox = $sync->sync('sandbox');
$resultLive = $sync->sync('live');
$this->logAdminAction('paypal_plan_sync', [
'sandbox' => $resultSandbox,
'live' => $resultLive,
]);
return back()->with('status', 'PayPal plans synced (sandbox + live).');
}
public function createPricingSnapshot(): RedirectResponse
{
$before = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $before,
]);
$this->logAdminAction('pricing_snapshot', ['plans' => count($before)]);
return back()->with('status', 'Pricing snapshot created.');
}
public function resetPricing(): RedirectResponse
{
$defaults = config('dewemoji.pricing.defaults', []);
$before = PricingPlan::orderBy('id')->get()->toArray();
DB::transaction(function () use ($defaults): void {
PricingPlan::query()->delete();
foreach ($defaults as $plan) {
PricingPlan::create([
'code' => $plan['code'],
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'],
'status' => $plan['status'],
'meta' => $plan['meta'] ?? null,
]);
}
});
$after = PricingPlan::orderBy('id')->get()->toArray();
PricingChange::create([
'admin_ref' => (string) auth()->user()?->email,
'before' => $before,
'after' => $after,
]);
$this->logAdminAction('pricing_reset', ['plans' => count($after)]);
return back()->with('status', 'Pricing reset to defaults.');
}
public function webhooks(Request $request): View
{
$query = WebhookEvent::query();
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
if ($provider = $request->query('provider')) {
$query->where('provider', (string) $provider);
}
if ($status = $request->query('status')) {
$query->where('status', (string) $status);
}
$events = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
$providers = WebhookEvent::query()->distinct('provider')->orderBy('provider')->pluck('provider');
return view('dashboard.admin.webhooks', [
'events' => $events,
'filters' => [
'provider' => $request->query('provider'),
'status' => $request->query('status'),
],
'providers' => $providers,
'sort' => $sort,
'dir' => $dir,
]);
}
public function webhookDetail(WebhookEvent $event): View
{
return view('dashboard.admin.webhook-show', [
'event' => $event,
]);
}
public function replayWebhook(int $id): RedirectResponse
{
$event = WebhookEvent::find($id);
if (!$event) {
return back()->withErrors(['webhook' => 'Webhook not found.']);
}
$event->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
$this->logAdminAction('webhook_replay', ['event_id' => $event->id]);
return back()->with('status', 'Webhook queued for replay.');
}
public function replayFailedWebhooks(): RedirectResponse
{
$count = WebhookEvent::where('status', 'error')->count();
WebhookEvent::where('status', 'error')->update([
'status' => 'pending',
'processed_at' => null,
'error' => null,
]);
$this->logAdminAction('webhook_replay_failed', ['count' => $count]);
return back()->with('status', 'Failed webhooks queued for replay.');
}
public function settings(): View
{
$all = $this->settings->all();
return view('dashboard.admin.settings', [
'settings' => $all,
]);
}
public function auditLogs(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$action = trim((string) $request->query('action', ''));
$query = AdminAuditLog::query()->orderByDesc('id');
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('admin_email', 'like', '%'.$q.'%')
->orWhere('action', 'like', '%'.$q.'%');
});
}
if ($action !== '') {
$query->where('action', $action);
}
$actions = AdminAuditLog::query()->distinct('action')->orderBy('action')->pluck('action');
$logs = $query->paginate(25)->withQueryString();
return view('dashboard.admin.audit-logs', [
'logs' => $logs,
'filters' => ['q' => $q, 'action' => $action],
'actions' => $actions,
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'maintenance_enabled' => 'nullable|boolean',
'public_enforce' => 'nullable|boolean',
'public_origins' => 'nullable|string',
'public_extension_ids' => 'nullable|string',
'public_hourly_limit' => 'nullable|integer|min:0',
'billing_mode' => 'nullable|string|in:sandbox,live',
]);
$payload = [
'maintenance_enabled' => (bool) ($data['maintenance_enabled'] ?? false),
'public_enforce' => (bool) ($data['public_enforce'] ?? false),
'public_origins' => $this->splitCsv($data['public_origins'] ?? ''),
'public_extension_ids' => $this->splitCsv($data['public_extension_ids'] ?? ''),
'public_hourly_limit' => (int) ($data['public_hourly_limit'] ?? 0),
'billing_mode' => $data['billing_mode'] ?? null,
];
$this->settings->setMany($payload, (string) auth()->user()?->email);
$this->logAdminAction('settings_update', $payload);
return back()->with('status', 'Settings updated.');
}
private function resolveUser(?int $userId, ?string $email): ?User
{
if ($userId) {
return User::find($userId);
}
if ($email) {
return User::where('email', $email)->first();
}
return null;
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
public function exportCsv(Request $request, string $type): StreamedResponse
{
$type = strtolower($type);
$filename = "dewemoji-{$type}-export-".now()->format('Ymd_His').".csv";
return response()->streamDownload(function () use ($type, $request): void {
$out = fopen('php://output', 'w');
if ($type === 'users') {
$q = trim((string) $request->query('q', ''));
$tier = trim((string) $request->query('tier', ''));
$role = trim((string) $request->query('role', ''));
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = User::query();
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('email', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%');
});
}
if ($tier !== '') {
$query->where('tier', $tier);
}
if ($role !== '') {
$query->where('role', $role);
}
fputcsv($out, ['id', 'name', 'email', 'role', 'tier', 'created_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->name,
$row->email,
$row->role,
$row->tier,
optional($row->created_at)->toDateTimeString(),
]);
}
});
} elseif ($type === 'subscriptions') {
$userId = $request->query('user_id');
$email = $request->query('email');
$status = $request->query('status');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = Subscription::query()->with('user:id,email');
if ($userId) {
$query->where('user_id', (int) $userId);
}
if ($email) {
$query->whereHas('user', fn ($q) => $q->where('email', $email));
}
if ($status) {
$query->where('status', (string) $status);
}
fputcsv($out, ['id', 'user_id', 'email', 'plan', 'status', 'started_at', 'expires_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->user_id,
$row->user?->email,
$row->plan,
$row->status,
optional($row->started_at)->toDateTimeString(),
optional($row->expires_at)->toDateTimeString(),
]);
}
});
} elseif ($type === 'webhooks') {
$provider = $request->query('provider');
$status = $request->query('status');
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
$dir = $this->sanitizeDir($request->query('dir'));
$query = WebhookEvent::query();
if ($provider) {
$query->where('provider', (string) $provider);
}
if ($status) {
$query->where('status', (string) $status);
}
fputcsv($out, ['id', 'provider', 'event_type', 'status', 'received_at', 'processed_at']);
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$row->provider,
$row->event_type,
$row->status,
optional($row->received_at)->toDateTimeString(),
optional($row->processed_at)->toDateTimeString(),
]);
}
});
} else {
fputcsv($out, ['error']);
fputcsv($out, ['unsupported_export_type']);
}
fclose($out);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function logAdminAction(string $action, array $payload = []): void
{
if (!Schema::hasTable('admin_audit_logs')) {
return;
}
$user = auth()->user();
AdminAuditLog::create([
'admin_id' => $user?->id,
'admin_email' => $user?->email,
'action' => $action,
'payload' => $payload,
'ip_address' => request()->ip(),
]);
}
private function sanitizeSort(mixed $value, array $allowed, string $fallback): string
{
$sort = is_string($value) ? $value : '';
return in_array($sort, $allowed, true) ? $sort : $fallback;
}
private function sanitizeDir(mixed $value): string
{
return $value === 'asc' ? 'asc' : 'desc';
}
/**
* @return array<int,string>
*/
private function splitCsv(string $value): array
{
$items = array_filter(array_map('trim', explode(',', $value)));
return array_values($items);
}
}

View File

@@ -0,0 +1,452 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Http\Controllers\Api\V1\EmojiApiController;
use App\Services\Auth\ApiKeyService;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class UserDashboardController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
) {
}
public function overview(Request $request): View
{
$user = $request->user();
if (Gate::allows('admin')) {
$days = 7;
$start = now()->subDays($days - 1)->startOfDay();
$labels = [];
$values = [];
$subsValues = [];
$webhookValues = [];
$rawUsers = DB::table('users')
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
$rawSubs = DB::table('subscriptions')
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
$rawWebhooks = DB::table('webhook_events')
->selectRaw('DATE(COALESCE(received_at, created_at)) as day, COUNT(*) as total')
->where('created_at', '>=', $start)
->groupBy('day')
->orderBy('day')
->get()
->keyBy('day');
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$labels[] = Carbon::parse($date)->format('M d');
$values[] = (int) ($rawUsers[$date]->total ?? 0);
$subsValues[] = (int) ($rawSubs[$date]->total ?? 0);
$webhookValues[] = (int) ($rawWebhooks[$date]->total ?? 0);
}
$usersTotal = User::count();
$usersPersonal = User::where('tier', 'personal')->count();
$subscriptionsActive = Subscription::where('status', 'active')->count();
$subscriptionsTotal = Subscription::count();
$webhookTotal = WebhookEvent::count();
$webhookErrors = WebhookEvent::where('status', 'error')->count();
return view('dashboard.index', [
'chartLabels' => $labels,
'chartValues' => $values,
'chartSubs' => $subsValues,
'chartWebhooks' => $webhookValues,
'overviewMetrics' => [
'users_total' => $usersTotal,
'users_personal' => $usersPersonal,
'subscriptions_active' => $subscriptionsActive,
'subscriptions_total' => $subscriptionsTotal,
'webhook_total' => $webhookTotal,
'webhook_errors' => $webhookErrors,
],
]);
}
$recentKeywords = UserKeyword::where('user_id', $user?->id)
->orderByDesc('id')
->limit(6)
->get();
$recentWeekCount = UserKeyword::where('user_id', $user?->id)
->where('created_at', '>=', now()->subDays(7))
->count();
$totalKeywords = UserKeyword::where('user_id', $user?->id)->count();
$apiKeyCount = UserApiKey::where('user_id', $user?->id)
->whereNull('revoked_at')
->count();
$activeSubscription = Subscription::where('user_id', $user?->id)
->orderByDesc('started_at')
->first();
return view('dashboard.user.overview', [
'totalKeywords' => $totalKeywords,
'recentKeywords' => $recentKeywords,
'recentWeekCount' => $recentWeekCount,
'apiKeyCount' => $apiKeyCount,
'subscription' => $activeSubscription,
]);
}
public function keywords(Request $request): View
{
$user = $request->user();
$items = UserKeyword::where('user_id', $user?->id)
->orderByDesc('id')
->get();
$emojiLookup = [];
$dataPath = (string) config('dewemoji.data_path');
if (is_file($dataPath)) {
$raw = file_get_contents($dataPath);
$decoded = json_decode((string) $raw, true);
if (is_array($decoded)) {
foreach ($decoded['emojis'] ?? [] as $row) {
$slug = (string) ($row['slug'] ?? '');
if ($slug !== '') {
$emojiLookup[$slug] = [
'emoji' => (string) ($row['emoji'] ?? ''),
'name' => (string) ($row['name'] ?? $slug),
];
}
}
}
}
return view('dashboard.user.keywords', [
'items' => $items,
'user' => $user,
'emojiLookup' => $emojiLookup,
'freeLimit' => $this->keywordLimitFor($user),
]);
}
public function keywordSearch(Request $request, EmojiApiController $emoji): JsonResponse
{
$request->query->set('private', 'true');
return $emoji->search($request);
}
public function storeKeyword(Request $request): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
if ($limit = $this->keywordLimitFor($user)) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return $this->rejectKeywordLimit($request, $limit);
}
}
}
$item = UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $data['lang'] ?? 'und',
]
);
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $item]);
}
return back()->with('status', 'Keyword saved.');
}
public function updateKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ($keyword->user_id !== $user->id) {
abort(403);
}
$data = $request->validate([
'emoji_slug' => 'required|string|max:120',
'keyword' => 'required|string|max:200',
'lang' => 'nullable|string|max:10',
]);
$duplicate = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
if ($duplicate && $duplicate->id !== $keyword->id) {
$keyword->delete();
$keyword = $duplicate;
} else {
$keyword->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
}
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $keyword]);
}
return back()->with('status', 'Keyword updated.');
}
public function deleteKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ($keyword->user_id !== $user->id) {
abort(403);
}
$keyword->delete();
if ($request->expectsJson()) {
return response()->json(['ok' => true]);
}
return back()->with('status', 'Keyword removed.');
}
public function importKeywords(Request $request): RedirectResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$payload = $request->input('payload');
if ($request->hasFile('file')) {
$payload = $request->file('file')->get();
}
$items = json_decode((string) $payload, true);
if (!is_array($items)) {
return back()->withErrors(['payload' => 'Invalid JSON payload.']);
}
$imported = 0;
$skipped = 0;
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$emojiSlug = trim((string) ($row['emoji_slug'] ?? ''));
$keyword = trim((string) ($row['keyword'] ?? ''));
$lang = trim((string) ($row['lang'] ?? 'und'));
if ($emojiSlug === '' || $keyword === '') {
continue;
}
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $emojiSlug)
->where('keyword', $keyword)
->exists();
if (!$exists && $limit !== null && $current >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
'user_id' => $user->id,
'emoji_slug' => $emojiSlug,
'keyword' => $keyword,
],
[
'lang' => $lang !== '' ? $lang : 'und',
]
);
if (!$exists) {
$current += 1;
}
$imported += 1;
}
$message = "Imported {$imported} keywords.";
if ($skipped > 0) {
$message .= " {$skipped} skipped (free limit reached).";
}
return back()->with('status', $message);
}
public function exportKeywords(Request $request): BinaryFileResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang'])
->values()
->toJson(JSON_PRETTY_PRINT);
$filename = 'dewemoji-keywords-'.$user->id.'-'.now()->format('Ymd-His').'.json';
$path = storage_path('app/'.$filename);
file_put_contents($path, $items);
return response()->download($path, $filename)->deleteFileAfterSend(true);
}
public function apiKeys(Request $request): View
{
$user = $request->user();
$keys = UserApiKey::where('user_id', $user?->id)
->orderByDesc('id')
->get();
$canCreate = $user && (string) $user->tier === 'personal';
return view('dashboard.user.api-keys', [
'user' => $user,
'keys' => $keys,
'canCreate' => $canCreate,
'newKey' => session('new_api_key'),
]);
}
public function createApiKey(Request $request): RedirectResponse
{
$user = $request->user();
if (!$user) {
abort(403);
}
if ((string) $user->tier !== 'personal') {
return back()->withErrors(['api_key' => 'API keys are available on the Personal plan.']);
}
$data = $request->validate([
'name' => 'nullable|string|max:100',
]);
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
return back()->with('new_api_key', $issued['plain']);
}
public function revokeApiKey(Request $request, UserApiKey $key): RedirectResponse
{
$user = $request->user();
if (!$user || $key->user_id !== $user->id) {
abort(403);
}
$key->update(['revoked_at' => Carbon::now()]);
return back()->with('status', 'API key revoked.');
}
public function billing(Request $request): View
{
$user = $request->user();
$subscription = Subscription::where('user_id', $user?->id)
->orderByDesc('started_at')
->first();
$orders = Order::where('user_id', $user?->id)
->orderByDesc('id')
->limit(5)
->get();
$payments = Payment::where('user_id', $user?->id)
->orderByDesc('id')
->limit(5)
->get();
return view('dashboard.user.billing', [
'subscription' => $subscription,
'user' => $user,
'orders' => $orders,
'payments' => $payments,
]);
}
public function preferences(Request $request): View
{
return view('dashboard.user.preferences');
}
private function rejectKeywordLimit(Request $request, int $limit): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
}
return back()->withErrors(['tier' => "Free plan limit reached ({$limit} keywords)."]);
}
private function keywordLimitFor(?User $user): ?int
{
if (!$user) {
return null;
}
if ((string) $user->tier === 'personal') {
return null;
}
return (int) config('dewemoji.pagination.free_max_limit', 20);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('dashboard.user.profile', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
$emailChanged = $request->user()->isDirty('email');
if ($emailChanged) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
if ($emailChanged && $request->user() instanceof MustVerifyEmail) {
$request->user()->sendEmailVerificationNotification();
}
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\PricingPlan;
use App\Models\UserKeyword;
use App\Services\System\SettingsService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,6 +26,12 @@ class SiteController extends Controller
'Flags' => 'flags',
];
private function billingMode(): string
{
$settings = app(SettingsService::class);
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
}
public function home(Request $request): View
{
return view('site.home', [
@@ -30,6 +39,7 @@ class SiteController extends Controller
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/',
'userTier' => $request->user()?->tier,
]);
}
@@ -45,19 +55,21 @@ class SiteController extends Controller
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/browse',
'userTier' => $request->user()?->tier,
]);
}
public function category(string $categorySlug): View
{
if ($categorySlug === 'all') {
return view('site.home', [
'initialQuery' => '',
'initialCategory' => '',
'initialSubcategory' => '',
'canonicalPath' => '/',
]);
}
return view('site.home', [
'initialQuery' => '',
'initialCategory' => '',
'initialSubcategory' => '',
'canonicalPath' => '/',
'userTier' => request()->user()?->tier,
]);
}
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
abort_if($categoryLabel === '', 404);
@@ -67,6 +79,7 @@ class SiteController extends Controller
'initialCategory' => $categoryLabel,
'initialSubcategory' => '',
'canonicalPath' => '/'.$categorySlug,
'userTier' => request()->user()?->tier,
]);
}
@@ -84,6 +97,7 @@ class SiteController extends Controller
'initialCategory' => $categoryLabel,
'initialSubcategory' => $subcategorySlug,
'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug,
'userTier' => request()->user()?->tier,
]);
}
@@ -94,7 +108,96 @@ class SiteController extends Controller
public function pricing(): View
{
return view('site.pricing');
$currencyPref = strtoupper((string) session('pricing_currency', ''));
if (!in_array($currencyPref, ['IDR', 'USD'], true)) {
$currencyPref = $this->detectPricingCurrency(request());
session(['pricing_currency' => $currencyPref]);
}
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
$plans = PricingPlan::where('status', 'active')->get()->keyBy('code');
$defaults = config('dewemoji.pricing.defaults', []);
$fallback = collect($defaults)->keyBy('code');
$getPlanAmount = function (string $code) use ($plans, $fallback): int {
$plan = $plans->get($code) ?? $fallback->get($code);
return (int) ($plan['amount'] ?? $plan->amount ?? 0);
};
$pricing = [
'personal_monthly' => [
'idr' => $getPlanAmount('personal_monthly'),
],
'personal_annual' => [
'idr' => $getPlanAmount('personal_annual'),
],
'personal_lifetime' => [
'idr' => $getPlanAmount('personal_lifetime'),
],
];
foreach ($pricing as $key => $row) {
$pricing[$key]['usd'] = $rate > 0 ? round($row['idr'] / $rate, 2) : 0;
}
return view('site.pricing', [
'currencyPref' => $currencyPref,
'usdRate' => $rate,
'pricing' => $pricing,
'payments' => [
'qris_url' => (string) config('dewemoji.payments.qris_url', ''),
'paypal_url' => (string) config('dewemoji.payments.paypal_url', ''),
],
'pakasirEnabled' => (bool) config('dewemoji.billing.providers.pakasir.enabled', false)
&& (string) config('dewemoji.billing.providers.pakasir.api_base', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.api_key', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.project', '') !== '',
'paypalEnabled' => $this->paypalEnabled($this->billingMode()),
'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()),
]);
}
private function paypalEnabled(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_id", '');
$clientSecret = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_secret", '');
$apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base", '');
return $enabled && $clientId !== '' && $clientSecret !== '' && $apiBase !== '';
}
private function paypalPlanAvailability(string $mode): array
{
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get()->keyBy('code');
$fromDb = function (string $code) use ($plans, $mode): bool {
$plan = $plans->get($code);
if (!$plan) {
return false;
}
$meta = $plan->meta ?? [];
return (string) ($meta['paypal'][$mode]['plan']['id'] ?? '') !== '';
};
$fromEnv = function (string $code) use ($mode): bool {
return (string) config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$code}", '') !== '';
};
return [
'personal_monthly' => $fromDb('personal_monthly') || $fromEnv('personal_monthly'),
'personal_annual' => $fromDb('personal_annual') || $fromEnv('personal_annual'),
];
}
public function setPricingCurrency(Request $request): RedirectResponse
{
$data = $request->validate([
'currency' => 'required|string|in:IDR,USD',
]);
session(['pricing_currency' => $data['currency']]);
return back();
}
public function support(): View
@@ -112,6 +215,18 @@ class SiteController extends Controller
return view('site.terms');
}
private function detectPricingCurrency(Request $request): string
{
$country = strtoupper((string) ($request->header('CF-IPCountry')
?? $request->header('X-Country-Code')
?? $request->header('X-Geo-Country')
?? $request->header('X-Appengine-Country')
?? $request->header('CloudFront-Viewer-Country')
?? ''));
return $country === 'ID' ? 'IDR' : 'USD';
}
public function emojiDetail(string $slug): View|Response
{
$dataPath = (string) config('dewemoji.data_path');
@@ -157,10 +272,22 @@ class SiteController extends Controller
];
}
$user = request()->user();
$isPersonal = $user && (string) $user->tier === 'personal';
$userKeywords = [];
if ($isPersonal) {
$userKeywords = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get();
}
return view('site.emoji-detail', [
'emoji' => $match,
'relatedDetails' => $relatedDetails,
'canonicalPath' => '/emoji/'.$slug,
'userKeywords' => $userKeywords,
'userTier' => $user?->tier,
]);
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Mail;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Email;
class MailketingTransport extends AbstractTransport
{
public function __construct(
protected string $apiToken,
protected string $endpoint,
protected ?string $defaultFromName = null,
protected ?string $defaultFromEmail = null,
protected int $timeoutSeconds = 10,
) {
parent::__construct();
}
public function __toString(): string
{
return 'mailketing';
}
protected function doSend(SentMessage $message): void
{
$email = $message->getOriginalMessage();
if (! $email instanceof Email) {
throw new TransportException('Mailketing transport only supports Symfony Email messages.');
}
if ($email->getAttachments()) {
throw new TransportException('Mailketing transport does not support attachments without URL references.');
}
$from = $email->getFrom()[0] ?? null;
$fromName = $from?->getName() ?: $this->defaultFromName ?: '';
$fromEmail = $from?->getAddress() ?: $this->defaultFromEmail ?: '';
if ($fromEmail === '') {
throw new TransportException('Mailketing transport requires a from email address.');
}
$subject = $email->getSubject() ?? '';
$content = $email->getHtmlBody() ?? $email->getTextBody() ?? '';
$recipients = array_merge(
$email->getTo(),
$email->getCc(),
$email->getBcc(),
);
if ($recipients === []) {
throw new TransportException('Mailketing transport requires at least one recipient.');
}
foreach ($recipients as $recipient) {
$response = Http::asForm()
->timeout($this->timeoutSeconds)
->post($this->endpoint, [
'api_token' => $this->apiToken,
'from_name' => $fromName,
'from_email' => $fromEmail,
'recipient' => $recipient->getAddress(),
'subject' => $subject,
'content' => $content,
]);
if (! $response->ok()) {
throw new TransportException(sprintf(
'Mailketing request failed (%s): %s',
$response->status(),
$response->body()
));
}
$payload = $response->json();
if (is_array($payload) && (($payload['status'] ?? null) !== 'success')) {
$detail = $payload['response'] ?? $payload['message'] ?? 'unknown';
throw new TransportException('Mailketing responded with an error: '.$detail);
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TestMailketing extends Mailable
{
use Queueable, SerializesModels;
public function build(): self
{
$actionUrl = config('app.url', 'https://dewemoji.com');
return $this
->subject('Dewemoji test email')
->view('emails.test', [
'actionUrl' => $actionUrl,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminAuditLog extends Model
{
protected $fillable = [
'admin_id',
'admin_email',
'action',
'payload',
'ip_address',
];
protected $casts = [
'payload' => 'array',
];
}

31
app/app/Models/Order.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'plan_code',
'type',
'currency',
'amount',
'status',
'provider',
'provider_ref',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $fillable = [
'user_id',
'order_id',
'provider',
'type',
'plan_code',
'currency',
'amount',
'status',
'provider_ref',
'raw_payload',
];
protected $casts = [
'raw_payload' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PricingChange extends Model
{
protected $fillable = [
'admin_ref',
'before',
'after',
];
protected $casts = [
'before' => 'array',
'after' => 'array',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PricingPlan extends Model
{
protected $fillable = [
'code',
'name',
'currency',
'amount',
'period',
'status',
'meta',
];
protected $casts = [
'amount' => 'integer',
'meta' => 'array',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'key',
'value',
'updated_by',
];
protected $casts = [
'value' => 'array',
];
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'plan',
'status',
'provider',
'provider_ref',
'started_at',
'expires_at',
'canceled_at',
'next_renewal_at',
];
protected $casts = [
'started_at' => 'datetime',
'expires_at' => 'datetime',
'canceled_at' => 'datetime',
'next_renewal_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -2,15 +2,18 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Auth\MustVerifyEmail;
use App\Notifications\ResetPasswordNotification;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmailContract
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory, Notifiable, MustVerifyEmail;
/**
* The attributes that are mass assignable.
@@ -21,6 +24,8 @@ class User extends Authenticatable
'name',
'email',
'password',
'role',
'tier',
];
/**
@@ -45,4 +50,19 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmailNotification());
}
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token));
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserApiKey extends Model
{
protected $fillable = [
'user_id',
'key_hash',
'key_prefix',
'name',
'last_used_at',
'revoked_at',
];
protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserKeyword extends Model
{
protected $fillable = [
'user_id',
'emoji_slug',
'keyword',
'lang',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = [
'provider',
'event_id',
'event_type',
'status',
'payload',
'headers',
'error',
'received_at',
'processed_at',
];
protected $casts = [
'payload' => 'array',
'headers' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPasswordNotification extends ResetPassword
{
public function toMail($notifiable): MailMessage
{
$resetUrl = $this->resetUrl($notifiable);
return (new MailMessage)
->subject('Reset your password')
->view('emails.reset-password', [
'resetUrl' => $resetUrl,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
class VerifyEmailNotification extends VerifyEmail
{
public function toMail($notifiable): MailMessage
{
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify your email')
->view('emails.verify-email', [
'verificationUrl' => $verificationUrl,
]);
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
use App\Mail\MailketingTransport;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Gate::define('admin', function ($user) {
return $user && method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
});
Mail::extend('mailketing', function (array $config) {
return new MailketingTransport(
apiToken: (string) ($config['token'] ?? ''),
endpoint: (string) ($config['endpoint'] ?? 'https://api.mailketing.co.id/api/v1/send'),
defaultFromName: config('mail.from.name'),
defaultFromEmail: config('mail.from.address'),
timeoutSeconds: (int) ($config['timeout'] ?? 10),
);
});
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Models\UserApiKey;
use Carbon\Carbon;
use Illuminate\Http\Request;
class ApiKeyService
{
public function parseKey(Request $request): string
{
$auth = trim((string) $request->header('Authorization', ''));
if (str_starts_with($auth, 'Bearer ')) {
return trim(substr($auth, 7));
}
return trim((string) $request->header('X-Api-Key', ''));
}
public function hashKey(string $key): string
{
return hash('sha256', $key);
}
public function prefix(string $key): string
{
return substr($key, 0, 8);
}
public function resolveUser(Request $request): ?User
{
$key = $this->parseKey($request);
if ($key === '') {
return null;
}
$record = UserApiKey::where('key_hash', $this->hashKey($key))
->whereNull('revoked_at')
->first();
if (!$record) {
return null;
}
$record->last_used_at = Carbon::now();
$record->save();
$user = $record->user;
if (!$user || (string) $user->tier !== 'personal') {
return null;
}
return $user;
}
public function issueKey(User $user, ?string $name = null): array
{
$plain = 'dew_'.bin2hex(random_bytes(16));
$hash = $this->hashKey($plain);
$prefix = $this->prefix($plain);
$record = UserApiKey::create([
'user_id' => $user->id,
'key_hash' => $hash,
'key_prefix' => $prefix,
'name' => $name,
]);
return [
'plain' => $plain,
'record' => $record,
];
}
}

View File

@@ -0,0 +1,319 @@
<?php
namespace App\Services\Billing;
use App\Models\PricingPlan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PayPalPlanSyncService
{
/**
* @return array{created:int,updated:int,deactivated:int,skipped:int}
*/
public function sync(string $mode = 'sandbox'): array
{
$result = [
'created' => 0,
'updated' => 0,
'deactivated' => 0,
'skipped' => 0,
];
$token = $this->getAccessToken($mode);
if (!$token) {
Log::warning('PayPal plan sync aborted: missing access token', ['mode' => $mode]);
return $result;
}
$productId = $this->ensureProduct($mode, $token);
if (!$productId) {
Log::warning('PayPal plan sync aborted: missing product id', ['mode' => $mode]);
return $result;
}
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get();
$keepIds = [];
foreach ($plans as $plan) {
$currentMeta = $plan->meta ?? [];
$currentPlanId = $currentMeta['paypal'][$mode]['plan']['id'] ?? null;
$history = $currentMeta['paypal'][$mode]['plan']['history'] ?? [];
if ($currentPlanId) {
$history = array_values(array_unique(array_merge([$currentPlanId], $history)));
}
$amountUsd = $this->toUsdAmount($plan->amount);
$existing = $currentPlanId ? $this->getPlan($mode, $token, $currentPlanId) : null;
$existingAmount = $existing['billing_cycles'][0]['pricing_scheme']['fixed_price']['value'] ?? null;
if ($existing && $existingAmount !== null && (float) $existingAmount === (float) $amountUsd) {
$currentMeta['paypal'][$mode]['plan'] = [
'id' => $currentPlanId,
'amount_usd' => $amountUsd,
'history' => $history,
'synced_at' => now()->toIso8601String(),
];
$plan->update(['meta' => $currentMeta]);
if ($currentPlanId) {
$keepIds[] = $currentPlanId;
}
$result['skipped']++;
continue;
}
$newPlanId = $this->createPlan($mode, $token, $productId, $plan->code, $plan->name, $amountUsd, $plan->period);
if (!$newPlanId) {
$result['skipped']++;
continue;
}
$currentMeta['paypal'][$mode]['plan'] = [
'id' => $newPlanId,
'amount_usd' => $amountUsd,
'history' => $history,
'synced_at' => now()->toIso8601String(),
];
$plan->update(['meta' => $currentMeta]);
if ($currentPlanId) {
$result['updated']++;
} else {
$result['created']++;
}
$keepIds[] = $newPlanId;
if ($this->canDeactivate($plan->code)) {
foreach ($history as $oldId) {
if ($oldId === $newPlanId) {
continue;
}
if ($this->deactivatePlan($mode, $token, $oldId)) {
$result['deactivated']++;
}
}
}
}
// Deactivate any other active plans under this product to avoid confusion
$deactivated = $this->deactivateOtherPlansForProduct($mode, $token, $productId, $keepIds);
$result['deactivated'] += $deactivated;
return $result;
}
private function toUsdAmount(int $idrAmount): string
{
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
$usd = $rate > 0 ? $idrAmount / $rate : 0;
return number_format(max($usd, 1), 2, '.', '');
}
private function canDeactivate(string $planCode): bool
{
$activeCount = \App\Models\Subscription::query()
->where('provider', 'paypal')
->where('status', 'active')
->where('plan', $planCode)
->count();
return $activeCount === 0;
}
private function getAccessToken(string $mode): ?string
{
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if (!$clientId || !$clientSecret || !$apiBase) {
return null;
}
$res = Http::asForm()
->withBasicAuth($clientId, $clientSecret)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (!$res->ok()) {
Log::warning('PayPal auth failed', ['body' => $res->body()]);
return null;
}
return $res->json('access_token');
}
private function ensureProduct(string $mode, string $token): ?string
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$list = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/catalogs/products', [
'page_size' => 20,
'page' => 1,
]);
if ($list->ok()) {
$items = $list->json('products') ?? [];
foreach ($items as $item) {
if (($item['custom_id'] ?? '') === 'dewemoji-personal') {
return $item['id'] ?? null;
}
}
}
$payload = [
'name' => 'Dewemoji Personal',
'type' => 'SERVICE',
'category' => 'SOFTWARE',
'custom_id' => 'dewemoji-personal',
];
$create = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/catalogs/products', $payload);
$createdId = $create->json('id');
if ($createdId) {
if (!$create->ok()) {
Log::warning('PayPal product create returned non-OK but provided id', [
'status' => $create->status(),
'body' => $create->body(),
]);
}
return $createdId;
}
Log::warning('PayPal product create failed', [
'status' => $create->status(),
'body' => $create->body(),
]);
return null;
}
private function createPlan(
string $mode,
string $token,
string $productId,
string $code,
string $name,
string $amountUsd,
?string $period
): ?string {
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$intervalUnit = $period === 'year' ? 'YEAR' : 'MONTH';
$payload = [
'product_id' => $productId,
'name' => $name,
'description' => "{$name} subscription",
'billing_cycles' => [
[
'frequency' => [
'interval_unit' => $intervalUnit,
'interval_count' => 1,
],
'tenure_type' => 'REGULAR',
'sequence' => 1,
'total_cycles' => 0,
'pricing_scheme' => [
'fixed_price' => [
'value' => $amountUsd,
'currency_code' => 'USD',
],
],
],
],
'payment_preferences' => [
'auto_bill_outstanding' => true,
'setup_fee_failure_action' => 'CONTINUE',
'payment_failure_threshold' => 3,
],
];
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/plans', $payload);
$planId = $res->json('id');
if ($planId) {
if (!$res->ok()) {
Log::warning('PayPal plan create returned non-OK but provided id', [
'code' => $code,
'status' => $res->status(),
'body' => $res->body(),
]);
}
return $planId;
}
Log::warning('PayPal plan create failed', [
'code' => $code,
'status' => $res->status(),
'body' => $res->body(),
]);
return null;
}
private function getPlan(string $mode, string $token, string $planId): ?array
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId);
if (!$res->ok()) {
return null;
}
return $res->json();
}
private function deactivatePlan(string $mode, string $token, string $planId): bool
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId.'/deactivate');
return $res->ok();
}
private function deactivateOtherPlansForProduct(string $mode, string $token, string $productId, array $keepIds): int
{
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->get(rtrim($apiBase, '/').'/v1/billing/plans', [
'product_id' => $productId,
'page_size' => 20,
'page' => 1,
]);
if (!$res->ok()) {
return 0;
}
$items = $res->json('plans') ?? [];
if (!is_array($items)) {
return 0;
}
$count = 0;
foreach ($items as $plan) {
$id = $plan['id'] ?? null;
$status = strtoupper((string) ($plan['status'] ?? ''));
if (!$id || in_array($id, $keepIds, true)) {
continue;
}
if ($status !== 'ACTIVE') {
continue;
}
if ($this->deactivatePlan($mode, $token, $id)) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Billing;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
class PaypalWebhookProcessor
{
/**
* @param array<string,mixed> $payload
*/
public function process(string $eventType, array $payload): void
{
$resource = $payload['resource'] ?? [];
if (!is_array($resource)) {
return;
}
$subscriptionId = (string) ($resource['id'] ?? '');
$email = (string) ($resource['subscriber']['email_address'] ?? '');
if ($subscriptionId === '' || $email === '') {
return;
}
$user = User::where('email', $email)->first();
if (!$user) {
throw new \RuntimeException('User not found for webhook email.');
}
if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') {
Subscription::create([
'user_id' => $user->id,
'plan' => 'personal',
'status' => 'active',
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
'started_at' => now(),
'expires_at' => null,
]);
$user->update(['tier' => 'personal']);
return;
}
if ($eventType === 'BILLING.SUBSCRIPTION.CANCELLED' || $eventType === 'BILLING.SUBSCRIPTION.SUSPENDED') {
Subscription::where('user_id', $user->id)
->where('provider', 'paypal')
->where('provider_ref', $subscriptionId)
->where('status', 'active')
->update([
'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended',
'expires_at' => now(),
]);
$this->syncUserTier($user->id);
}
}
private function syncUserTier(int $userId): void
{
$active = Subscription::where('user_id', $userId)
->where('status', 'active')
->where(function ($q): void {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Extension;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ExtensionVerificationService
{
/**
* @param array<string> $expectedExtensionIds
*/
public function verifyToken(string $token, array $expectedExtensionIds): bool
{
if ($token === '') {
return false;
}
$config = config('dewemoji.extension_verification', []);
if (!(bool) ($config['enabled'] ?? true)) {
return true;
}
$projectId = (string) ($config['project_id'] ?? '');
$serverKey = (string) ($config['server_key'] ?? '');
if ($projectId === '' || $serverKey === '') {
return false;
}
$cacheKey = 'dw_ext_verify_'.sha1($token);
$ttl = max((int) ($config['cache_ttl'] ?? 3600), 60);
return Cache::remember($cacheKey, $ttl, function () use ($token, $serverKey, $expectedExtensionIds): bool {
$url = 'https://iid.googleapis.com/iid/info/'.$token.'?details=true';
$response = Http::withHeaders([
'Authorization' => 'key='.$serverKey,
])->get($url);
if (!$response->ok()) {
return false;
}
$data = $response->json();
if (!is_array($data)) {
return false;
}
$application = (string) ($data['application'] ?? '');
if ($application === '') {
return false;
}
if (count($expectedExtensionIds) === 0) {
return true;
}
return in_array($application, $expectedExtensionIds, true);
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services\System;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
class SettingsService
{
private const CACHE_KEY = 'dw_settings_all';
private const CACHE_TTL = 30;
/**
* @return array<string,mixed>
*/
public function all(): array
{
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
$out = [];
foreach (Setting::all(['key', 'value']) as $setting) {
$out[$setting->key] = $setting->value;
}
return $out;
});
}
public function get(string $key, mixed $default = null): mixed
{
$all = $this->all();
return array_key_exists($key, $all) ? $all[$key] : $default;
}
/**
* @param array<string,mixed> $values
*/
public function setMany(array $values, ?string $updatedBy = null): void
{
foreach ($values as $key => $value) {
Setting::updateOrCreate(
['key' => (string) $key],
['value' => $value, 'updated_by' => $updatedBy]
);
}
Cache::forget(self::CACHE_KEY);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}

View File

@@ -7,7 +7,10 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
web: [
__DIR__.'/../routes/web.php',
__DIR__.'/../routes/dashboard.php',
],
api: __DIR__.'/../routes/api.php',
apiPrefix: '',
commands: __DIR__.'/../routes/console.php',

View File

@@ -15,6 +15,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/breeze": "*",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",

63
app/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "79cd7bb4d53c32c6fa51bec0f240bd28",
"content-hash": "4f99c59819e467f4dda33565265ea286",
"packages": [
{
"name": "brick/math",
@@ -6182,6 +6182,67 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "laravel/breeze",
"version": "v2.3.8",
"source": {
"type": "git",
"url": "https://github.com/laravel/breeze.git",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/breeze/zipball/1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0",
"illuminate/filesystem": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"illuminate/validation": "^11.0|^12.0",
"php": "^8.2.0",
"symfony/console": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0",
"orchestra/testbench-core": "^9.0|^10.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Breeze\\BreezeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Breeze\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/breeze/issues",
"source": "https://github.com/laravel/breeze"
},
"time": "2025-07-18T18:49:59+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.4",

View File

@@ -44,19 +44,109 @@ return [
// Optional stub keys for local testing without external HTTP calls.
'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_TEST_KEYS', ''))))),
],
'paypal' => [
'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL),
'timeout' => (int) env('DEWEMOJI_PAYPAL_TIMEOUT', 10),
'webhook_ids' => [
'sandbox' => env('DEWEMOJI_PAYPAL_SANDBOX_WEBHOOK_ID', ''),
'live' => env('DEWEMOJI_PAYPAL_LIVE_WEBHOOK_ID', ''),
],
'plan_ids' => [
'sandbox' => [
'personal_monthly' => env('DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_MONTHLY', ''),
'personal_annual' => env('DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_ANNUAL', ''),
],
'live' => [
'personal_monthly' => env('DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_MONTHLY', ''),
'personal_annual' => env('DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_ANNUAL', ''),
],
],
'sandbox' => [
'api_base' => env('DEWEMOJI_PAYPAL_SANDBOX_API_BASE', 'https://api-m.sandbox.paypal.com'),
'web_base' => env('DEWEMOJI_PAYPAL_SANDBOX_WEB_BASE', 'https://www.sandbox.paypal.com'),
'client_id' => env('DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID', ''),
'client_secret' => env('DEWEMOJI_PAYPAL_SANDBOX_CLIENT_SECRET', ''),
],
'live' => [
'api_base' => env('DEWEMOJI_PAYPAL_LIVE_API_BASE', 'https://api-m.paypal.com'),
'web_base' => env('DEWEMOJI_PAYPAL_LIVE_WEB_BASE', 'https://www.paypal.com'),
'client_id' => env('DEWEMOJI_PAYPAL_LIVE_CLIENT_ID', ''),
'client_secret' => env('DEWEMOJI_PAYPAL_LIVE_CLIENT_SECRET', ''),
],
],
'pakasir' => [
'enabled' => filter_var(env('DEWEMOJI_PAKASIR_ENABLED', false), FILTER_VALIDATE_BOOL),
'api_base' => env('DEWEMOJI_PAKASIR_API_BASE', ''),
'api_key' => env('DEWEMOJI_PAKASIR_API_KEY', ''),
'project' => env('DEWEMOJI_PAKASIR_PROJECT', ''),
'timeout' => (int) env('DEWEMOJI_PAKASIR_TIMEOUT', 10),
],
],
],
'cors' => [
'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_ALLOWED_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))),
'allow_methods' => 'GET, POST, OPTIONS',
'allow_headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend',
'allow_headers' => 'Content-Type, Authorization, X-License-Key, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
],
'admin' => [
'token' => env('DEWEMOJI_ADMIN_TOKEN', ''),
],
'pricing' => [
'usd_rate' => (int) env('DEWEMOJI_USD_RATE', 15000),
'defaults' => [
[
'code' => 'personal_monthly',
'name' => 'Personal Monthly',
'currency' => 'IDR',
'amount' => 30000,
'period' => 'month',
'status' => 'active',
],
[
'code' => 'personal_annual',
'name' => 'Personal Annual',
'currency' => 'IDR',
'amount' => 300000,
'period' => 'year',
'status' => 'active',
],
[
'code' => 'personal_lifetime',
'name' => 'Personal Lifetime',
'currency' => 'IDR',
'amount' => 900000,
'period' => null,
'status' => 'active',
],
],
],
'payments' => [
'qris_url' => env('DEWEMOJI_QRIS_URL', ''),
'paypal_url' => env('DEWEMOJI_PAYPAL_URL', ''),
],
'frontend' => [
'header_token' => env('DEWEMOJI_FRONTEND_HEADER', 'web-v1'),
],
'public_access' => [
'enforce_whitelist' => filter_var(env('DEWEMOJI_PUBLIC_ENFORCE', true), FILTER_VALIDATE_BOOL),
'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_PUBLIC_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))),
'extension_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_EXTENSION_IDS', ''))))),
'hourly_limit' => (int) env('DEWEMOJI_PUBLIC_HOURLY_LIMIT', 5000),
],
'extension_verification' => [
'enabled' => filter_var(env('DEWEMOJI_EXTENSION_VERIFY_ENABLED', true), FILTER_VALIDATE_BOOL),
'project_id' => env('DEWEMOJI_GOOGLE_PROJECT_ID', ''),
'server_key' => env('DEWEMOJI_GOOGLE_SERVER_KEY', ''),
'cache_ttl' => (int) env('DEWEMOJI_EXTENSION_VERIFY_CACHE_TTL', 3600),
],
'metrics' => [
'enabled' => filter_var(env('DEWEMOJI_METRICS_ENABLED', true), FILTER_VALIDATE_BOOL),
'token' => (string) env('DEWEMOJI_METRICS_TOKEN', ''),

View File

@@ -61,6 +61,13 @@ return [
// ],
],
'mailketing' => [
'transport' => 'mailketing',
'endpoint' => env('MAILKETING_API_URL', 'https://api.mailketing.co.id/api/v1/send'),
'token' => env('MAILKETING_API_TOKEN'),
'timeout' => env('MAILKETING_TIMEOUT', 10),
],
'resend' => [
'transport' => 'resend',
],

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('tier', 20)->default('free')->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('tier');
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_api_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('key_hash', 64)->unique();
$table->string('key_prefix', 12)->index();
$table->string('name', 100)->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_api_keys');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_keywords', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('emoji_slug');
$table->string('keyword', 200);
$table->string('lang', 10)->default('und');
$table->timestamps();
$table->unique(['user_id', 'emoji_slug', 'keyword']);
$table->index(['user_id', 'emoji_slug']);
});
}
public function down(): void
{
Schema::dropIfExists('user_keywords');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('plan', 20);
$table->string('status', 20)->default('active');
$table->string('provider', 20)->nullable();
$table->string('provider_ref', 100)->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->timestamp('next_renewal_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pricing_plans', function (Blueprint $table) {
$table->id();
$table->string('code', 30)->unique();
$table->string('name', 50);
$table->string('currency', 10)->default('IDR');
$table->unsignedBigInteger('amount');
$table->string('period', 20)->nullable();
$table->string('status', 20)->default('active');
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('pricing_plans');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pricing_changes', function (Blueprint $table) {
$table->id();
$table->string('admin_ref', 120)->nullable();
$table->json('before')->nullable();
$table->json('after')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('pricing_changes');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table): void {
$table->id();
$table->string('key', 120)->unique();
$table->json('value')->nullable();
$table->string('updated_by', 120)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table): void {
$table->id();
$table->string('provider', 50);
$table->string('event_type', 120)->nullable();
$table->string('status', 50)->default('received');
$table->json('payload')->nullable();
$table->text('error')->nullable();
$table->timestamp('received_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('webhook_events', function (Blueprint $table): void {
$table->string('event_id', 120)->nullable()->after('provider');
$table->json('headers')->nullable()->after('payload');
$table->index(['provider', 'event_id']);
});
}
public function down(): void
{
Schema::table('webhook_events', function (Blueprint $table): void {
$table->dropIndex(['provider', 'event_id']);
$table->dropColumn('event_id');
$table->dropColumn('headers');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role', 32)->default('user')->after('password');
$table->index('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['role']);
$table->dropColumn('role');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('admin_audit_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('admin_id')->nullable()->index();
$table->string('admin_email', 255)->nullable()->index();
$table->string('action', 64)->index();
$table->json('payload')->nullable();
$table->string('ip_address', 64)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('admin_audit_logs');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('plan_code', 40);
$table->string('type', 20); // one_time | subscription
$table->string('currency', 10);
$table->unsignedInteger('amount');
$table->string('status', 20)->default('pending');
$table->string('provider', 20)->nullable();
$table->string('provider_ref', 100)->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['provider', 'provider_ref']);
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->string('provider', 20);
$table->string('type', 20); // one_time | subscription
$table->string('plan_code', 40);
$table->string('currency', 10);
$table->unsignedInteger('amount');
$table->string('status', 20)->default('pending');
$table->string('provider_ref', 100)->nullable();
$table->json('raw_payload')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['provider', 'provider_ref']);
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};

View File

@@ -15,11 +15,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call(PricingPlanSeeder::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use App\Models\PricingPlan;
use Illuminate\Database\Seeder;
class PricingPlanSeeder extends Seeder
{
public function run(): void
{
$defaults = config('dewemoji.pricing.defaults', []);
foreach ($defaults as $plan) {
PricingPlan::updateOrCreate(
['code' => $plan['code']],
[
'name' => $plan['name'],
'currency' => $plan['currency'],
'amount' => $plan['amount'],
'period' => $plan['period'],
'status' => $plan['status'],
'meta' => $plan['meta'] ?? null,
]
);
}
}
}

View File

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

@@ -0,0 +1,143 @@
# 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.

3581
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,19 @@
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.11.0",
"chart.js": "^4.4.1",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.0.7"
},
"dependencies": {
"apexcharts": "^5.3.6"
}
}

6
app/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin invite</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
Admin access
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🧭</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
Admin invite
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
You have been granted admin access for Dewemoji. Use the link below to enter the admin dashboard.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
Role: {{ role_name }}<br />
Granted by: {{ granted_by }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ admin_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Open Admin Dashboard
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If you do not expect this invite, please ignore the message.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Admins can manage users, subscriptions, and pricing.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API key created</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
API key
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🔐</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
API key created
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
A new API key was created for your Dewemoji account.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
Label: {{ key_label }}<br />
Created: {{ created_at }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Manage API Keys
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If you did not create this key, revoke it immediately.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Never share your API key publicly. Treat it like a password.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API key rotated</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
API key
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🔁</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
API key rotated
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
Your API key was rotated. Update any integrations using the old key.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
Label: {{ key_label }}<br />
Rotated: {{ rotated_at }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
View API Keys
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If you did not rotate this key, revoke it immediately.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Keep your API key secure and do not commit it to public repos.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dewemoji Email Verification</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
Email verification
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;"></span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
Verify your email
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:20px;">
Thanks for joining Dewemoji. Confirm your email to activate your account and start saving emoji keywords.
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ verification_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Verify Email
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If the button does not work, paste this link into your browser:
<br />
<span style="color:#2563eb;">{{ verification_url }}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
This link expires in 60 minutes. If you did not request this email, you can safely ignore it.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>License key issued</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
License
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🔑</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
License key issued
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
Your license key is ready. Use it to unlock Dewemoji in the extension and API.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
License: {{ license_key }}<br />
Tier: {{ tier }}<br />
Devices: {{ max_devices }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Manage License
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Keep your license secure. You can rotate it anytime from your dashboard.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Need help getting started? Visit your API docs from the dashboard.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>License key updated</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
License
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🧩</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
License updated
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
Your Dewemoji license details have been updated.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
License: {{ license_key }}<br />
Tier: {{ tier }}<br />
Devices: {{ max_devices }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
View License
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If you did not request this change, please contact support.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
Keep your license secure and avoid sharing it publicly.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New login detected</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
Security alert
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🛡️</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
New login detected
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
We noticed a new login to your Dewemoji account.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
Time: {{ login_time }}<br />
Device: {{ login_device }}<br />
Location: {{ login_location }}<br />
IP: {{ login_ip }}
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ security_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Review Activity
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If this was you, no action is needed.
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If you do not recognize this login, secure your account immediately.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dewemoji Password Reset</title>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
<tr>
<td style="padding:26px 28px 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
</td>
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
Dewemoji
</td>
</tr>
</table>
</td>
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
Password reset
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:12px 28px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
<tr>
<td style="padding-bottom:14px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
<span style="display:inline-block; font-size:18px; line-height:34px;">🔒</span>
</td>
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
Reset your password
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:20px;">
We received a request to reset your Dewemoji password. Click the button below to choose a new one.
</td>
</tr>
<tr>
<td align="left" style="padding-bottom:18px;">
<a href="{{ reset_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
Reset Password
</a>
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
If the button does not work, paste this link into your browser:
<br />
<span style="color:#2563eb;">{{ reset_url }}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 28px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
<tr>
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
This link expires in 60 minutes. If you did not request a password reset, you can safely ignore this email.
</td>
</tr>
<tr>
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
Dewemoji • Emoji discovery and keywords for creators
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More