Commit 57e37bea authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: add push notification management system for EL3AB mobile app

Connects to Supabase (source of truth) to query player profiles and
send real-time push notifications by inserting into the notifications
table. Supports broadcast, demographic-filtered, and individual sends
with scheduling, templates, and full campaign history.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c2e440e4
...@@ -11,3 +11,6 @@ DB_PASS=Alarcade123# ...@@ -11,3 +11,6 @@ DB_PASS=Alarcade123#
SMS_PROVIDER= SMS_PROVIDER=
SMS_API_KEY= SMS_API_KEY=
SMS_SENDER_ID= SMS_SENDER_ID=
SUPABASE_URL=https://safe-supabase-kong.caprover.al-arcade.com
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\PushNotifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\PushNotifications\Services\SupabasePushService;
class CampaignController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$page = max(1, (int) $request->get('page', 1));
$perPage = 25;
$offset = ($page - 1) * $perPage;
$statusFilter = $request->get('status', '');
$typeFilter = $request->get('type', '');
$where = ['1=1'];
$params = [];
if ($statusFilter) {
$where[] = 'status = ?';
$params[] = $statusFilter;
}
if ($typeFilter) {
$where[] = 'type = ?';
$params[] = $typeFilter;
}
$whereClause = implode(' AND ', $where);
$total = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM push_campaigns WHERE {$whereClause}", $params)['cnt'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$campaigns = $db->select(
"SELECT pc.*, u.full_name_ar as creator_name
FROM push_campaigns pc
LEFT JOIN users u ON u.id = pc.created_by
WHERE {$whereClause}
ORDER BY pc.created_at DESC
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
$stats = SupabasePushService::getPlayerStats();
return $this->view('PushNotifications.Views.campaigns', [
'campaigns' => $campaigns,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => $lastPage,
],
'filters' => ['status' => $statusFilter, 'type' => $typeFilter],
'stats' => $stats,
]);
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$campaign = $db->selectOne(
"SELECT pc.*, u.full_name_ar as creator_name
FROM push_campaigns pc
LEFT JOIN users u ON u.id = pc.created_by
WHERE pc.id = ?",
[(int) $id]
);
if (!$campaign) {
return $this->redirect('/push/campaigns')->withError('الحملة غير موجودة');
}
$recipients = $db->select(
"SELECT * FROM push_campaign_recipients WHERE campaign_id = ? ORDER BY sent_at DESC LIMIT 200",
[(int) $id]
);
return $this->view('PushNotifications.Views.campaign_detail', [
'campaign' => $campaign,
'recipients' => $recipients,
]);
}
public function cancel(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$campaign = $db->selectOne("SELECT * FROM push_campaigns WHERE id = ? AND status = 'scheduled'", [(int) $id]);
if (!$campaign) {
return $this->redirect('/push/campaigns')->withError('لا يمكن إلغاء هذه الحملة');
}
$db->update('push_campaigns', ['status' => 'cancelled'], 'id = ?', [(int) $id]);
return $this->redirect('/push/campaigns')->withSuccess('تم إلغاء الحملة المجدولة');
}
public function delete(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$db->query("DELETE FROM push_campaign_recipients WHERE campaign_id = ?", [(int) $id]);
$db->query("DELETE FROM push_campaigns WHERE id = ?", [(int) $id]);
return $this->redirect('/push/campaigns')->withSuccess('تم حذف الحملة');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PushNotifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\PushNotifications\Services\SupabasePushService;
class ComposeController extends Controller
{
public function form(Request $request): Response
{
$db = App::getInstance()->db();
$templates = $db->select("SELECT * FROM push_templates WHERE is_active = 1 ORDER BY name");
$stats = SupabasePushService::getPlayerStats();
return $this->view('PushNotifications.Views.compose', [
'templates' => $templates,
'stats' => $stats,
]);
}
public function preview(Request $request): Response
{
$filters = $this->extractFilters($request);
$count = SupabasePushService::countPlayers($filters);
return $this->json([
'count' => $count,
'filters' => $filters,
]);
}
public function send(Request $request): Response
{
$titleAr = trim((string) $request->post('title_ar', ''));
$bodyAr = trim((string) $request->post('body_ar', ''));
$notifType = trim((string) $request->post('notification_type', 'announcement'));
$targetType = trim((string) $request->post('target_type', 'broadcast'));
if ($titleAr === '' || $bodyAr === '') {
return $this->redirect('/push/compose')->withError('العنوان ونص الإشعار مطلوبان');
}
$db = App::getInstance()->db();
$session = App::getInstance()->session();
$userId = $session->get('user_id');
$filters = $targetType === 'demographic' ? $this->extractFilters($request) : [];
// Create campaign record
$db->insert('push_campaigns', [
'title' => $titleAr,
'title_ar' => $titleAr,
'body_ar' => $bodyAr,
'type' => $targetType === 'broadcast' ? 'broadcast' : 'demographic',
'status' => 'sending',
'filters_json' => !empty($filters) ? json_encode($filters, JSON_UNESCAPED_UNICODE) : null,
'notification_type' => $notifType,
'data_json' => null,
'created_by' => $userId,
]);
$campaignId = (int) $db->lastInsertId();
// Gather target users
$allUserIds = [];
$allUsers = [];
if ($targetType === 'broadcast') {
foreach (SupabasePushService::getAllPlayerIds() as $batch) {
foreach ($batch as $player) {
$allUserIds[] = $player['id'];
$allUsers[] = $player;
}
}
} else {
$offset = 0;
while (true) {
$batch = SupabasePushService::queryPlayers($filters, 1000, $offset);
if (empty($batch)) break;
foreach ($batch as $player) {
$allUserIds[] = $player['id'];
$allUsers[] = $player;
}
if (count($batch) < 1000) break;
$offset += 1000;
}
}
if (empty($allUserIds)) {
$db->update('push_campaigns', ['status' => 'failed', 'target_count' => 0], 'id = ?', [$campaignId]);
return $this->redirect('/push/campaigns')->withError('لم يتم العثور على لاعبين مطابقين للمعايير');
}
// Update target count
$db->update('push_campaigns', ['target_count' => count($allUserIds)], 'id = ?', [$campaignId]);
// Insert recipients
foreach ($allUsers as $user) {
$db->insert('push_campaign_recipients', [
'campaign_id' => $campaignId,
'user_id' => $user['id'],
'display_name' => $user['display_name'] ?? null,
'status' => 'pending',
]);
}
// Send notifications via Supabase
$dataPayload = ['campaign_id' => $campaignId];
$result = SupabasePushService::sendToUsers($allUserIds, $titleAr, $bodyAr, $notifType, $dataPayload);
// Update campaign status
$db->update('push_campaigns', [
'status' => $result['failed'] > 0 && $result['sent'] === 0 ? 'failed' : 'sent',
'sent_count' => $result['sent'],
'failed_count' => $result['failed'],
'sent_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$campaignId]);
// Update recipient statuses
if ($result['sent'] > 0) {
$db->query(
"UPDATE push_campaign_recipients SET status = 'sent', sent_at = NOW() WHERE campaign_id = ?",
[$campaignId]
);
}
$msg = "تم إرسال الإشعار إلى {$result['sent']} لاعب";
if ($result['failed'] > 0) {
$msg .= " (فشل: {$result['failed']})";
}
return $this->redirect('/push/campaigns/' . $campaignId)->withSuccess($msg);
}
private function extractFilters(Request $request): array
{
$filters = [];
$levelMin = $request->post('level_min', $request->get('level_min', ''));
$levelMax = $request->post('level_max', $request->get('level_max', ''));
$gamesMin = $request->post('games_min', $request->get('games_min', ''));
$coinsMin = $request->post('coins_min', $request->get('coins_min', ''));
$coinsMax = $request->post('coins_max', $request->get('coins_max', ''));
$activity = $request->post('activity', $request->get('activity', ''));
$country = $request->post('country', $request->get('country', ''));
$registeredAfter = $request->post('registered_after', $request->get('registered_after', ''));
$registeredBefore = $request->post('registered_before', $request->get('registered_before', ''));
if ($levelMin !== '') $filters['level_min'] = (int) $levelMin;
if ($levelMax !== '') $filters['level_max'] = (int) $levelMax;
if ($gamesMin !== '') $filters['games_min'] = (int) $gamesMin;
if ($coinsMin !== '') $filters['coins_min'] = (int) $coinsMin;
if ($coinsMax !== '') $filters['coins_max'] = (int) $coinsMax;
if ($activity !== '') $filters['activity'] = $activity;
if ($country !== '') $filters['country'] = $country;
if ($registeredAfter !== '') $filters['registered_after'] = $registeredAfter;
if ($registeredBefore !== '') $filters['registered_before'] = $registeredBefore;
return $filters;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PushNotifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\PushNotifications\Services\SupabasePushService;
class IndividualController extends Controller
{
public function form(Request $request): Response
{
$db = App::getInstance()->db();
$templates = $db->select("SELECT * FROM push_templates WHERE is_active = 1 ORDER BY name");
return $this->view('PushNotifications.Views.individual', [
'templates' => $templates,
]);
}
public function search(Request $request): Response
{
$query = trim((string) $request->get('q', ''));
if (strlen($query) < 2) {
return $this->json(['players' => []]);
}
$players = SupabasePushService::searchPlayers($query);
return $this->json(['players' => $players]);
}
public function send(Request $request): Response
{
$userId = trim((string) $request->post('user_id', ''));
$titleAr = trim((string) $request->post('title_ar', ''));
$bodyAr = trim((string) $request->post('body_ar', ''));
$notifType = trim((string) $request->post('notification_type', 'announcement'));
$displayName = trim((string) $request->post('display_name', ''));
if ($userId === '') {
return $this->redirect('/push/individual')->withError('يرجى اختيار لاعب');
}
if ($titleAr === '' || $bodyAr === '') {
return $this->redirect('/push/individual')->withError('العنوان ونص الإشعار مطلوبان');
}
$db = App::getInstance()->db();
$session = App::getInstance()->session();
$createdBy = $session->get('user_id');
// Create campaign record
$db->insert('push_campaigns', [
'title' => $titleAr,
'title_ar' => $titleAr,
'body_ar' => $bodyAr,
'type' => 'individual',
'status' => 'sending',
'target_count' => 1,
'notification_type' => $notifType,
'created_by' => $createdBy,
]);
$campaignId = (int) $db->lastInsertId();
// Insert recipient
$db->insert('push_campaign_recipients', [
'campaign_id' => $campaignId,
'user_id' => $userId,
'display_name' => $displayName ?: null,
'status' => 'pending',
]);
// Send
$success = SupabasePushService::sendToUser($userId, $titleAr, $bodyAr, $notifType, ['campaign_id' => $campaignId]);
// Update
$db->update('push_campaigns', [
'status' => $success ? 'sent' : 'failed',
'sent_count' => $success ? 1 : 0,
'failed_count' => $success ? 0 : 1,
'sent_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$campaignId]);
$db->update('push_campaign_recipients', [
'status' => $success ? 'sent' : 'failed',
'sent_at' => $success ? date('Y-m-d H:i:s') : null,
], 'campaign_id = ? AND user_id = ?', [$campaignId, $userId]);
if ($success) {
return $this->redirect('/push/campaigns/' . $campaignId)->withSuccess('تم إرسال الإشعار إلى ' . ($displayName ?: $userId));
}
return $this->redirect('/push/individual')->withError('فشل إرسال الإشعار');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PushNotifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class ScheduleController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$scheduled = $db->select(
"SELECT pc.*, u.full_name_ar as creator_name
FROM push_campaigns pc
LEFT JOIN users u ON u.id = pc.created_by
WHERE pc.status = 'scheduled'
ORDER BY pc.scheduled_at ASC"
);
$past = $db->select(
"SELECT pc.*, u.full_name_ar as creator_name
FROM push_campaigns pc
LEFT JOIN users u ON u.id = pc.created_by
WHERE pc.status IN ('sent', 'cancelled') AND pc.scheduled_at IS NOT NULL
ORDER BY pc.scheduled_at DESC
LIMIT 50"
);
return $this->view('PushNotifications.Views.scheduled', [
'scheduled' => $scheduled,
'past' => $past,
]);
}
public function schedule(Request $request): Response
{
$titleAr = trim((string) $request->post('title_ar', ''));
$bodyAr = trim((string) $request->post('body_ar', ''));
$notifType = trim((string) $request->post('notification_type', 'announcement'));
$targetType = trim((string) $request->post('target_type', 'broadcast'));
$scheduledAt = trim((string) $request->post('scheduled_at', ''));
if ($titleAr === '' || $bodyAr === '') {
return $this->redirect('/push/compose')->withError('العنوان ونص الإشعار مطلوبان');
}
if ($scheduledAt === '') {
return $this->redirect('/push/compose')->withError('يرجى تحديد وقت الجدولة');
}
$scheduledTime = new \DateTime($scheduledAt);
if ($scheduledTime <= new \DateTime()) {
return $this->redirect('/push/compose')->withError('وقت الجدولة يجب أن يكون في المستقبل');
}
$db = App::getInstance()->db();
$session = App::getInstance()->session();
$userId = $session->get('user_id');
$filters = $targetType === 'demographic' ? $this->extractFilters($request) : [];
$db->insert('push_campaigns', [
'title' => $titleAr,
'title_ar' => $titleAr,
'body_ar' => $bodyAr,
'type' => $targetType === 'broadcast' ? 'broadcast' : 'demographic',
'status' => 'scheduled',
'filters_json' => !empty($filters) ? json_encode($filters, JSON_UNESCAPED_UNICODE) : null,
'notification_type' => $notifType,
'scheduled_at' => $scheduledTime->format('Y-m-d H:i:s'),
'created_by' => $userId,
]);
return $this->redirect('/push/scheduled')->withSuccess('تم جدولة الإشعار في ' . $scheduledTime->format('Y-m-d H:i'));
}
private function extractFilters(Request $request): array
{
$filters = [];
$fields = ['level_min', 'level_max', 'games_min', 'coins_min', 'coins_max', 'activity', 'country', 'registered_after', 'registered_before'];
foreach ($fields as $field) {
$val = $request->post($field, '');
if ($val !== '') {
$filters[$field] = is_numeric($val) ? (int) $val : $val;
}
}
return $filters;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PushNotifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class TemplateController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$templates = $db->select("SELECT * FROM push_templates ORDER BY created_at DESC");
return $this->view('PushNotifications.Views.templates', [
'templates' => $templates,
]);
}
public function create(Request $request): Response
{
return $this->view('PushNotifications.Views.template_form', [
'template' => null,
]);
}
public function store(Request $request): Response
{
$name = trim((string) $request->post('name', ''));
$titleAr = trim((string) $request->post('title_ar', ''));
$bodyAr = trim((string) $request->post('body_ar', ''));
$notifType = trim((string) $request->post('notification_type', 'announcement'));
if ($name === '' || $titleAr === '' || $bodyAr === '') {
return $this->redirect('/push/templates/create')->withError('جميع الحقول مطلوبة');
}
$db = App::getInstance()->db();
$db->insert('push_templates', [
'name' => $name,
'title_ar' => $titleAr,
'body_ar' => $bodyAr,
'notification_type' => $notifType,
]);
return $this->redirect('/push/templates')->withSuccess('تم إنشاء القالب');
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$template = $db->selectOne("SELECT * FROM push_templates WHERE id = ?", [(int) $id]);
if (!$template) {
return $this->redirect('/push/templates')->withError('القالب غير موجود');
}
return $this->view('PushNotifications.Views.template_form', [
'template' => $template,
]);
}
public function update(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$template = $db->selectOne("SELECT * FROM push_templates WHERE id = ?", [(int) $id]);
if (!$template) {
return $this->redirect('/push/templates')->withError('القالب غير موجود');
}
$name = trim((string) $request->post('name', ''));
$titleAr = trim((string) $request->post('title_ar', ''));
$bodyAr = trim((string) $request->post('body_ar', ''));
$notifType = trim((string) $request->post('notification_type', 'announcement'));
$isActive = (int) $request->post('is_active', 1);
if ($name === '' || $titleAr === '' || $bodyAr === '') {
return $this->redirect('/push/templates/' . $id . '/edit')->withError('جميع الحقول مطلوبة');
}
$db->update('push_templates', [
'name' => $name,
'title_ar' => $titleAr,
'body_ar' => $bodyAr,
'notification_type' => $notifType,
'is_active' => $isActive,
], 'id = ?', [(int) $id]);
return $this->redirect('/push/templates')->withSuccess('تم تحديث القالب');
}
public function destroy(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$db->query("DELETE FROM push_templates WHERE id = ?", [(int) $id]);
return $this->redirect('/push/templates')->withSuccess('تم حذف القالب');
}
}
<?php
declare(strict_types=1);
return [
// Campaign history
['GET', '/push/campaigns', 'PushNotifications\Controllers\CampaignController@index', ['auth'], 'push.view'],
['GET', '/push/campaigns/{id}', 'PushNotifications\Controllers\CampaignController@show', ['auth'], 'push.view'],
['POST', '/push/campaigns/{id}/cancel', 'PushNotifications\Controllers\CampaignController@cancel', ['auth', 'csrf'], 'push.schedule'],
['POST', '/push/campaigns/{id}/delete', 'PushNotifications\Controllers\CampaignController@delete', ['auth', 'csrf'], 'push.delete'],
// Compose & send (demographic / broadcast)
['GET', '/push/compose', 'PushNotifications\Controllers\ComposeController@form', ['auth'], 'push.send'],
['POST', '/push/compose/preview', 'PushNotifications\Controllers\ComposeController@preview', ['auth', 'csrf'], 'push.send'],
['POST', '/push/compose/send', 'PushNotifications\Controllers\ComposeController@send', ['auth', 'csrf'], 'push.send'],
// Individual player
['GET', '/push/individual', 'PushNotifications\Controllers\IndividualController@form', ['auth'], 'push.send'],
['GET', '/push/individual/search', 'PushNotifications\Controllers\IndividualController@search', ['auth'], 'push.send'],
['POST', '/push/individual/send', 'PushNotifications\Controllers\IndividualController@send', ['auth', 'csrf'], 'push.send'],
// Templates
['GET', '/push/templates', 'PushNotifications\Controllers\TemplateController@index', ['auth'], 'push.templates'],
['GET', '/push/templates/create', 'PushNotifications\Controllers\TemplateController@create', ['auth'], 'push.templates'],
['POST', '/push/templates', 'PushNotifications\Controllers\TemplateController@store', ['auth', 'csrf'], 'push.templates'],
['GET', '/push/templates/{id}/edit', 'PushNotifications\Controllers\TemplateController@edit', ['auth'], 'push.templates'],
['POST', '/push/templates/{id}', 'PushNotifications\Controllers\TemplateController@update', ['auth', 'csrf'], 'push.templates'],
['POST', '/push/templates/{id}/delete', 'PushNotifications\Controllers\TemplateController@destroy', ['auth', 'csrf'], 'push.templates'],
// Scheduled
['GET', '/push/scheduled', 'PushNotifications\Controllers\ScheduleController@index', ['auth'], 'push.schedule'],
['POST', '/push/compose/schedule', 'PushNotifications\Controllers\ScheduleController@schedule', ['auth', 'csrf'], 'push.schedule'],
];
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تفاصيل الحملة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/push/campaigns" style="color:#0D7377;font-size:13px;">← العودة للحملات</a>
</div>
<div class="card" style="padding:24px;margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:20px;">
<div>
<h3 style="margin:0 0 8px;font-size:18px;color:#111827;"><?= e($campaign['title_ar']) ?></h3>
<p style="margin:0;color:#6B7280;font-size:13px;">
<?= match($campaign['type']) { 'broadcast' => 'بث عام', 'demographic' => 'فئة محددة', 'individual' => 'فردي', default => $campaign['type'] } ?>
— أُنشئت <?= e($campaign['created_at']) ?>
<?php if ($campaign['creator_name']): ?> بواسطة <?= e($campaign['creator_name']) ?><?php endif; ?>
</p>
</div>
<span style="padding:6px 14px;border-radius:8px;font-size:13px;font-weight:700;background:<?= match($campaign['status']) { 'sent' => '#D1FAE5', 'scheduled' => '#FEF3C7', 'failed' => '#FEE2E2', 'cancelled' => '#F3F4F6', default => '#EFF6FF' } ?>;color:<?= match($campaign['status']) { 'sent' => '#065F46', 'scheduled' => '#92400E', 'failed' => '#991B1B', 'cancelled' => '#374151', default => '#1D4ED8' } ?>;">
<?= match($campaign['status']) { 'sent' => 'تم الإرسال', 'scheduled' => 'مجدول', 'sending' => 'جاري الإرسال', 'failed' => 'فشل', 'cancelled' => 'ملغي', 'draft' => 'مسودة', default => $campaign['status'] } ?>
</span>
</div>
<div style="background:#F9FAFB;border-radius:12px;padding:16px;margin-bottom:16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">نص الإشعار</div>
<div style="font-size:14px;color:#111827;line-height:1.6;"><?= nl2br(e($campaign['body_ar'])) ?></div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;">
<div style="text-align:center;padding:12px;background:#F0FDF4;border-radius:8px;">
<div style="font-size:20px;font-weight:800;color:#059669;"><?= number_format((int) $campaign['sent_count']) ?></div>
<div style="font-size:11px;color:#6B7280;">مرسل</div>
</div>
<div style="text-align:center;padding:12px;background:#FEF2F2;border-radius:8px;">
<div style="font-size:20px;font-weight:800;color:#DC2626;"><?= number_format((int) $campaign['failed_count']) ?></div>
<div style="font-size:11px;color:#6B7280;">فشل</div>
</div>
<div style="text-align:center;padding:12px;background:#EFF6FF;border-radius:8px;">
<div style="font-size:20px;font-weight:800;color:#2563EB;"><?= number_format((int) $campaign['target_count']) ?></div>
<div style="font-size:11px;color:#6B7280;">المستهدف</div>
</div>
<div style="text-align:center;padding:12px;background:#F5F3FF;border-radius:8px;">
<div style="font-size:20px;font-weight:800;color:#7C3AED;"><?= e($campaign['notification_type']) ?></div>
<div style="font-size:11px;color:#6B7280;">النوع</div>
</div>
</div>
<?php if ($campaign['scheduled_at']): ?>
<div style="font-size:13px;color:#92400E;background:#FEF3C7;padding:10px 14px;border-radius:8px;margin-bottom:12px;">
مجدول في: <?= e($campaign['scheduled_at']) ?>
</div>
<?php endif; ?>
<?php if ($campaign['filters_json']): ?>
<div style="margin-bottom:12px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">الفلاتر المستخدمة:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<?php $filters = json_decode($campaign['filters_json'], true) ?? []; ?>
<?php foreach ($filters as $key => $value): ?>
<span style="font-size:11px;padding:4px 10px;background:#E0E7FF;color:#3730A3;border-radius:6px;"><?= e($key) ?>: <?= e((string) $value) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div style="display:flex;gap:8px;margin-top:16px;">
<?php if ($campaign['status'] === 'scheduled' && can('push.schedule')): ?>
<form method="POST" action="/push/campaigns/<?= (int) $campaign['id'] ?>/cancel" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('إلغاء هذه الحملة المجدولة؟')">إلغاء الجدولة</button>
</form>
<?php endif; ?>
<?php if (can('push.delete')): ?>
<form method="POST" action="/push/campaigns/<?= (int) $campaign['id'] ?>/delete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;" onclick="return confirm('حذف هذه الحملة نهائياً؟')">حذف</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if (!empty($recipients)): ?>
<div class="card">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;font-size:14px;color:#374151;">المستلمون (<?= count($recipients) ?>)</h4>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اللاعب</th>
<th>User ID</th>
<th>الحالة</th>
<th>وقت الإرسال</th>
</tr>
</thead>
<tbody>
<?php foreach ($recipients as $r): ?>
<tr>
<td style="font-size:13px;"><?= e($r['display_name'] ?? '—') ?></td>
<td style="font-size:11px;font-family:monospace;color:#6B7280;"><?= e($r['user_id']) ?></td>
<td>
<span style="color:<?= match($r['status']) { 'sent' => '#059669', 'failed' => '#DC2626', default => '#D97706' } ?>;font-weight:600;font-size:12px;">
<?= match($r['status']) { 'sent' => 'مرسل', 'failed' => 'فشل', default => 'انتظار' } ?>
</span>
</td>
<td style="font-size:12px;"><?= e($r['sent_at'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>حملات إشعارات التطبيق<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:24px;">
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:800;color:#0D7377;"><?= number_format($stats['total'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">إجمالي اللاعبين</div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:800;color:#10B981;"><?= number_format($stats['active_today'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">نشط اليوم</div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:800;color:#3B82F6;"><?= number_format($stats['active_week'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">نشط هذا الأسبوع</div>
</div>
<div class="card" style="padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:800;color:#8B5CF6;"><?= number_format($stats['active_month'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">نشط هذا الشهر</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;padding:15px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<form method="GET" action="/push/campaigns" style="display:flex;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<option value="sent" <?= ($filters['status'] ?? '') === 'sent' ? 'selected' : '' ?>>مرسل</option>
<option value="scheduled" <?= ($filters['status'] ?? '') === 'scheduled' ? 'selected' : '' ?>>مجدول</option>
<option value="sending" <?= ($filters['status'] ?? '') === 'sending' ? 'selected' : '' ?>>جاري الإرسال</option>
<option value="failed" <?= ($filters['status'] ?? '') === 'failed' ? 'selected' : '' ?>>فشل</option>
<option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغي</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select name="type" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<option value="broadcast" <?= ($filters['type'] ?? '') === 'broadcast' ? 'selected' : '' ?>>بث عام</option>
<option value="demographic" <?= ($filters['type'] ?? '') === 'demographic' ? 'selected' : '' ?>>فئة محددة</option>
<option value="individual" <?= ($filters['type'] ?? '') === 'individual' ? 'selected' : '' ?>>فردي</option>
</select>
</div>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
<div style="display:flex;gap:8px;">
<a href="/push/compose" class="btn btn-primary">إرسال إشعار جديد</a>
<a href="/push/individual" class="btn btn-outline">إرسال لفرد</a>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>العنوان</th>
<th>النوع</th>
<th>المستهدف</th>
<th>المرسل</th>
<th>الحالة</th>
<th>بواسطة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($campaigns as $c): ?>
<tr>
<td style="font-size:12px;white-space:nowrap;"><?= e($c['created_at']) ?></td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= e($c['title_ar']) ?></td>
<td><span style="font-size:11px;padding:3px 8px;border-radius:6px;background:<?= match($c['type']) { 'broadcast' => '#DBEAFE', 'demographic' => '#FEF3C7', 'individual' => '#E0E7FF', default => '#F3F4F6' } ?>;color:<?= match($c['type']) { 'broadcast' => '#1D4ED8', 'demographic' => '#92400E', 'individual' => '#4338CA', default => '#374151' } ?>;"><?= match($c['type']) { 'broadcast' => 'بث عام', 'demographic' => 'فئة', 'individual' => 'فردي', default => $c['type'] } ?></span></td>
<td style="font-size:13px;"><?= number_format((int) $c['target_count']) ?></td>
<td style="font-size:13px;"><?= number_format((int) $c['sent_count']) ?></td>
<td>
<span style="color:<?= match($c['status']) { 'sent' => '#059669', 'scheduled' => '#D97706', 'sending' => '#2563EB', 'failed' => '#DC2626', 'cancelled' => '#6B7280', default => '#6B7280' } ?>;font-weight:600;font-size:12px;">
<?= match($c['status']) { 'sent' => 'مرسل', 'scheduled' => 'مجدول', 'sending' => 'جاري', 'failed' => 'فشل', 'cancelled' => 'ملغي', 'draft' => 'مسودة', default => $c['status'] } ?>
</span>
</td>
<td style="font-size:12px;"><?= e($c['creator_name'] ?? '—') ?></td>
<td><a href="/push/campaigns/<?= (int) $c['id'] ?>" class="btn btn-sm btn-outline">تفاصيل</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($campaigns)): ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد حملات بعد</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pagination['last_page'] > 1): ?>
<div style="display:flex;justify-content:center;gap:6px;margin-top:16px;">
<?php for ($p = 1; $p <= $pagination['last_page']; $p++): ?>
<a href="/push/campaigns?page=<?= $p ?>&status=<?= e($filters['status'] ?? '') ?>&type=<?= e($filters['type'] ?? '') ?>"
style="padding:6px 12px;border-radius:6px;font-size:13px;<?= $p === $pagination['current_page'] ? 'background:#0D7377;color:#fff;' : 'background:#F3F4F6;color:#374151;' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إرسال إشعار لفرد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="max-width:700px;">
<div class="card" style="padding:24px;">
<h4 style="color:#0D7377;margin:0 0 20px;">إرسال إشعار لفرد</h4>
<form method="POST" action="/push/individual/send">
<?= csrf_field() ?>
<!-- Player search -->
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">ابحث عن اللاعب <span style="color:#DC2626;">*</span></label>
<div style="position:relative;">
<input type="text" id="player-search" class="form-input" placeholder="اسم اللاعب أو UUID..." autocomplete="off">
<div id="search-results" style="display:none;position:absolute;top:100%;left:0;right:0;background:#fff;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.1);max-height:250px;overflow-y:auto;z-index:50;"></div>
</div>
<input type="hidden" name="user_id" id="selected-user-id">
<input type="hidden" name="display_name" id="selected-display-name">
</div>
<!-- Selected player card -->
<div id="selected-player" style="display:none;padding:14px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:10px;margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong id="sel-name" style="font-size:14px;color:#065F46;"></strong>
<div id="sel-info" style="font-size:11px;color:#6B7280;margin-top:2px;"></div>
</div>
<button type="button" onclick="clearSelection()" style="background:none;border:none;color:#DC2626;cursor:pointer;font-size:18px;"></button>
</div>
</div>
<!-- Template -->
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">القالب (اختياري)</label>
<select id="template-select" class="form-select" onchange="applyTemplate()">
<option value="">— اكتب إشعار مخصص —</option>
<?php foreach ($templates as $t): ?>
<option value="<?= (int) $t['id'] ?>" data-title="<?= e($t['title_ar']) ?>" data-body="<?= e($t['body_ar']) ?>" data-type="<?= e($t['notification_type']) ?>"><?= e($t['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Notification content -->
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">عنوان الإشعار <span style="color:#DC2626;">*</span></label>
<input type="text" name="title_ar" id="title_ar" class="form-input" required maxlength="200" placeholder="عنوان الإشعار...">
</div>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">نص الإشعار <span style="color:#DC2626;">*</span></label>
<textarea name="body_ar" id="body_ar" class="form-textarea" rows="3" required maxlength="500" placeholder="نص الإشعار..."></textarea>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label">نوع الإشعار</label>
<select name="notification_type" id="notification_type" class="form-select">
<option value="announcement">إعلان</option>
<option value="match_invite">دعوة لعب</option>
<option value="daily_reward">مكافأة</option>
<option value="promotion">عرض خاص</option>
<option value="tournament_start">بطولة</option>
<option value="update">تحديث</option>
<option value="maintenance">صيانة</option>
</select>
</div>
<button type="submit" class="btn btn-primary" onclick="return validateForm()">إرسال الإشعار</button>
</form>
</div>
</div>
<script>
let searchTimeout;
const searchInput = document.getElementById('player-search');
const resultsDiv = document.getElementById('search-results');
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const q = this.value.trim();
if (q.length < 2) { resultsDiv.style.display = 'none'; return; }
searchTimeout = setTimeout(async () => {
try {
const resp = await fetch('/push/individual/search?q=' + encodeURIComponent(q));
const data = await resp.json();
if (data.players && data.players.length > 0) {
resultsDiv.innerHTML = data.players.map(p => `
<div onclick="selectPlayer('${p.id}', '${(p.display_name||'').replace(/'/g,"\\'")}', '${p.level||0}', '${p.games_played||0}', '${p.country||''}')"
style="padding:10px 14px;cursor:pointer;border-bottom:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;"
onmouseover="this.style.background='#F9FAFB'" onmouseout="this.style.background='#fff'">
<div>
<div style="font-size:13px;font-weight:600;">${p.display_name || 'بدون اسم'}</div>
<div style="font-size:10px;color:#9CA3AF;font-family:monospace;">${p.id}</div>
</div>
<div style="text-align:left;font-size:11px;color:#6B7280;">
Lv.${p.level || 0}${p.games_played || 0} لعبة${p.country ? ' • ' + p.country : ''}
</div>
</div>
`).join('');
resultsDiv.style.display = 'block';
} else {
resultsDiv.innerHTML = '<div style="padding:14px;text-align:center;color:#9CA3AF;font-size:13px;">لم يتم العثور على لاعبين</div>';
resultsDiv.style.display = 'block';
}
} catch(e) { resultsDiv.style.display = 'none'; }
}, 400);
});
document.addEventListener('click', function(e) {
if (!e.target.closest('#player-search') && !e.target.closest('#search-results')) {
resultsDiv.style.display = 'none';
}
});
function selectPlayer(id, name, level, games, country) {
document.getElementById('selected-user-id').value = id;
document.getElementById('selected-display-name').value = name;
document.getElementById('sel-name').textContent = name || 'بدون اسم';
document.getElementById('sel-info').textContent = `UUID: ${id} • Lv.${level}${games} لعبة` + (country ? ` • ${country}` : '');
document.getElementById('selected-player').style.display = 'block';
resultsDiv.style.display = 'none';
searchInput.value = name;
}
function clearSelection() {
document.getElementById('selected-user-id').value = '';
document.getElementById('selected-display-name').value = '';
document.getElementById('selected-player').style.display = 'none';
searchInput.value = '';
}
function applyTemplate() {
const sel = document.getElementById('template-select');
const opt = sel.options[sel.selectedIndex];
if (opt.value) {
document.getElementById('title_ar').value = opt.dataset.title || '';
document.getElementById('body_ar').value = opt.dataset.body || '';
document.getElementById('notification_type').value = opt.dataset.type || 'announcement';
}
}
function validateForm() {
if (!document.getElementById('selected-user-id').value) {
alert('يرجى اختيار لاعب أولاً');
return false;
}
return confirm('تأكيد إرسال الإشعار؟');
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الإشعارات المجدولة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h4 style="margin:0;color:#374151;">الإشعارات المجدولة</h4>
<a href="/push/compose" class="btn btn-primary">جدولة إشعار جديد</a>
</div>
<?php if (!empty($scheduled)): ?>
<div class="card" style="margin-bottom:24px;">
<div style="padding:12px 20px;border-bottom:1px solid #E5E7EB;background:#FEF3C7;">
<h5 style="margin:0;font-size:13px;color:#92400E;">قادمة (<?= count($scheduled) ?>)</h5>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>وقت الإرسال</th>
<th>العنوان</th>
<th>النوع</th>
<th>بواسطة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($scheduled as $s): ?>
<tr>
<td style="font-size:13px;font-weight:600;color:#92400E;"><?= e($s['scheduled_at']) ?></td>
<td style="font-size:13px;"><?= e($s['title_ar']) ?></td>
<td><span style="font-size:11px;padding:3px 8px;background:#DBEAFE;color:#1D4ED8;border-radius:6px;"><?= match($s['type']) { 'broadcast' => 'بث عام', 'demographic' => 'فئة', default => $s['type'] } ?></span></td>
<td style="font-size:12px;"><?= e($s['creator_name'] ?? '—') ?></td>
<td>
<form method="POST" action="/push/campaigns/<?= (int) $s['id'] ?>/cancel" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('إلغاء هذا الإشعار المجدول؟')">إلغاء</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;margin-bottom:24px;">
<div style="font-size:40px;margin-bottom:12px;">📅</div>
<div style="color:#6B7280;font-size:14px;">لا توجد إشعارات مجدولة</div>
<div style="color:#9CA3AF;font-size:12px;margin-top:6px;">يمكنك جدولة إشعار من صفحة الإرسال</div>
</div>
<?php endif; ?>
<?php if (!empty($past)): ?>
<div class="card">
<div style="padding:12px 20px;border-bottom:1px solid #E5E7EB;">
<h5 style="margin:0;font-size:13px;color:#6B7280;">سابقة</h5>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>وقت الجدولة</th>
<th>العنوان</th>
<th>الحالة</th>
<th>المرسل</th>
</tr>
</thead>
<tbody>
<?php foreach ($past as $p): ?>
<tr>
<td style="font-size:12px;"><?= e($p['scheduled_at']) ?></td>
<td style="font-size:13px;"><?= e($p['title_ar']) ?></td>
<td>
<span style="color:<?= $p['status'] === 'sent' ? '#059669' : '#6B7280' ?>;font-weight:600;font-size:12px;">
<?= $p['status'] === 'sent' ? 'مرسل' : 'ملغي' ?>
</span>
</td>
<td style="font-size:13px;"><?= number_format((int) $p['sent_count']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $template ? 'تعديل القالب' : 'قالب جديد' ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="max-width:600px;">
<div style="margin-bottom:16px;">
<a href="/push/templates" style="color:#0D7377;font-size:13px;">← العودة للقوالب</a>
</div>
<div class="card" style="padding:24px;">
<h4 style="color:#0D7377;margin:0 0 20px;"><?= $template ? 'تعديل القالب' : 'إنشاء قالب جديد' ?></h4>
<form method="POST" action="<?= $template ? '/push/templates/' . (int) $template['id'] : '/push/templates' ?>">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">اسم القالب <span style="color:#DC2626;">*</span></label>
<input type="text" name="name" class="form-input" required value="<?= e($template['name'] ?? '') ?>" placeholder="مثال: إعلان تحديث">
</div>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">عنوان الإشعار <span style="color:#DC2626;">*</span></label>
<input type="text" name="title_ar" class="form-input" required maxlength="200" value="<?= e($template['title_ar'] ?? '') ?>" placeholder="عنوان الإشعار...">
</div>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">نص الإشعار <span style="color:#DC2626;">*</span></label>
<textarea name="body_ar" class="form-textarea" rows="4" required maxlength="500" placeholder="نص الإشعار..."><?= e($template['body_ar'] ?? '') ?></textarea>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">نوع الإشعار</label>
<select name="notification_type" class="form-select">
<?php $types = ['announcement' => 'إعلان', 'update' => 'تحديث', 'tournament_start' => 'بطولة', 'daily_reward' => 'مكافأة', 'promotion' => 'عرض خاص', 'maintenance' => 'صيانة', 'match_invite' => 'دعوة لعب']; ?>
<?php foreach ($types as $val => $label): ?>
<option value="<?= $val ?>" <?= ($template['notification_type'] ?? '') === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($template): ?>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">الحالة</label>
<select name="is_active" class="form-select">
<option value="1" <?= ($template['is_active'] ?? 1) ? 'selected' : '' ?>>نشط</option>
<option value="0" <?= !($template['is_active'] ?? 1) ? 'selected' : '' ?>>معطّل</option>
</select>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary"><?= $template ? 'حفظ التعديلات' : 'إنشاء القالب' ?></button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قوالب الإشعارات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h4 style="margin:0;color:#374151;">قوالب الإشعارات</h4>
<?php if (can('push.templates')): ?>
<a href="/push/templates/create" class="btn btn-primary">قالب جديد</a>
<?php endif; ?>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الاسم</th>
<th>العنوان</th>
<th>النوع</th>
<th>الحالة</th>
<th>تاريخ الإنشاء</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($templates as $t): ?>
<tr>
<td style="font-size:13px;font-weight:600;"><?= e($t['name']) ?></td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= e($t['title_ar']) ?></td>
<td><span style="font-size:11px;padding:3px 8px;background:#EFF6FF;color:#1D4ED8;border-radius:6px;"><?= e($t['notification_type']) ?></span></td>
<td>
<span style="color:<?= $t['is_active'] ? '#059669' : '#DC2626' ?>;font-weight:600;font-size:12px;">
<?= $t['is_active'] ? 'نشط' : 'معطّل' ?>
</span>
</td>
<td style="font-size:12px;"><?= e($t['created_at']) ?></td>
<td style="display:flex;gap:6px;">
<a href="/push/templates/<?= (int) $t['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
<form method="POST" action="/push/templates/<?= (int) $t['id'] ?>/delete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('حذف هذا القالب؟')">حذف</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($templates)): ?>
<tr><td colspan="6" style="text-align:center;padding:40px;color:#6B7280;">لا توجد قوالب</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('push_notifications', [
'label_ar' => 'إشعارات التطبيق',
'label_en' => 'App Push',
'icon' => 'smartphone',
'route' => '/push/campaigns',
'permission' => 'push.view',
'parent' => null,
'order' => 775,
'children' => [
['label_ar' => 'الحملات', 'label_en' => 'Campaigns', 'route' => '/push/campaigns', 'permission' => 'push.view', 'order' => 1],
['label_ar' => 'إرسال إشعار', 'label_en' => 'Send Push', 'route' => '/push/compose', 'permission' => 'push.send', 'order' => 2],
['label_ar' => 'إرسال لفرد', 'label_en' => 'Send to Player', 'route' => '/push/individual', 'permission' => 'push.send', 'order' => 3],
['label_ar' => 'القوالب', 'label_en' => 'Templates', 'route' => '/push/templates', 'permission' => 'push.templates', 'order' => 4],
['label_ar' => 'الجدولة', 'label_en' => 'Scheduled', 'route' => '/push/scheduled', 'permission' => 'push.schedule', 'order' => 5],
],
]);
PermissionRegistry::register('push_notifications', [
'push.view' => ['ar' => 'عرض حملات الإشعارات', 'en' => 'View Push Campaigns'],
'push.send' => ['ar' => 'إرسال إشعارات فورية', 'en' => 'Send Push Notifications'],
'push.schedule' => ['ar' => 'جدولة إشعارات', 'en' => 'Schedule Push Notifications'],
'push.templates' => ['ar' => 'إدارة قوالب الإشعارات', 'en' => 'Manage Push Templates'],
'push.delete' => ['ar' => 'حذف حملات الإشعارات', 'en' => 'Delete Push Campaigns'],
]);
<?php
declare(strict_types=1);
namespace App\Shared\Services;
final class SupabaseService
{
private string $baseUrl;
private string $serviceKey;
private static ?self $instance = null;
private function __construct()
{
$this->baseUrl = rtrim((string) ($_ENV['SUPABASE_URL'] ?? getenv('SUPABASE_URL') ?: ''), '/');
$this->serviceKey = (string) ($_ENV['SUPABASE_SERVICE_KEY'] ?? getenv('SUPABASE_SERVICE_KEY') ?: '');
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function isConfigured(): bool
{
return $this->baseUrl !== '' && $this->serviceKey !== '';
}
public function get(string $table, array $params = []): array
{
$url = $this->baseUrl . '/rest/v1/' . $table;
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $this->request('GET', $url);
}
public function getCount(string $table, array $params = []): int
{
$url = $this->baseUrl . '/rest/v1/' . $table;
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
$headers = $this->headers();
$headers[] = 'Prefer: count=exact';
$headers[] = 'Range: 0-0';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headerStr = substr((string) $response, 0, $headerSize);
curl_close($ch);
if (preg_match('/content-range:\s*\d+-\d+\/(\d+)/i', $headerStr, $m)) {
return (int) $m[1];
}
return 0;
}
public function insert(string $table, array $data): array
{
$url = $this->baseUrl . '/rest/v1/' . $table;
return $this->request('POST', $url, $data);
}
public function insertBatch(string $table, array $rows): array
{
$url = $this->baseUrl . '/rest/v1/' . $table;
return $this->request('POST', $url, $rows, null, true);
}
public function update(string $table, array $data, array $params = []): array
{
$url = $this->baseUrl . '/rest/v1/' . $table;
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $this->request('PATCH', $url, $data);
}
public function rpc(string $fn, array $data = []): array
{
$url = $this->baseUrl . '/rest/v1/rpc/' . $fn;
return $this->request('POST', $url, $data);
}
private function headers(): array
{
return [
'apikey: ' . $this->serviceKey,
'Authorization: Bearer ' . $this->serviceKey,
'Content-Type: application/json',
'Prefer: return=representation',
];
}
private function request(string $method, string $url, ?array $body = null, ?array $customHeaders = null, bool $isBatch = false): array
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $customHeaders ?? $this->headers());
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
switch ($method) {
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($isBatch ? $body : $body));
}
break;
case 'PATCH':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
break;
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
return ['error' => 'Connection failed', 'code' => 0];
}
$decoded = json_decode((string) $response, true);
if ($httpCode >= 400) {
return ['error' => $decoded['message'] ?? $decoded['error'] ?? 'Request failed', 'code' => $httpCode];
}
return $decoded ?? [];
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\PushNotifications\Services\SupabasePushService;
/**
* Processes scheduled push notifications.
* Runs every minute to check for campaigns whose scheduled_at has passed.
*/
class PushNotificationCronJob
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$now = date('Y-m-d H:i:s');
$results = ['processed' => 0, 'sent' => 0, 'failed' => 0];
$campaigns = $this->db->select(
"SELECT * FROM push_campaigns WHERE status = 'scheduled' AND scheduled_at <= ?",
[$now]
);
foreach ($campaigns as $campaign) {
$results['processed']++;
$this->db->update('push_campaigns', ['status' => 'sending'], 'id = ?', [(int) $campaign['id']]);
try {
$filters = $campaign['filters_json'] ? json_decode($campaign['filters_json'], true) : [];
$allUserIds = [];
$allUsers = [];
if ($campaign['type'] === 'broadcast') {
foreach (SupabasePushService::getAllPlayerIds() as $batch) {
foreach ($batch as $player) {
$allUserIds[] = $player['id'];
$allUsers[] = $player;
}
}
} else {
$offset = 0;
while (true) {
$batch = SupabasePushService::queryPlayers($filters, 1000, $offset);
if (empty($batch)) break;
foreach ($batch as $player) {
$allUserIds[] = $player['id'];
$allUsers[] = $player;
}
if (count($batch) < 1000) break;
$offset += 1000;
}
}
if (empty($allUserIds)) {
$this->db->update('push_campaigns', [
'status' => 'failed',
'target_count' => 0,
], 'id = ?', [(int) $campaign['id']]);
$results['failed']++;
continue;
}
$this->db->update('push_campaigns', ['target_count' => count($allUserIds)], 'id = ?', [(int) $campaign['id']]);
foreach ($allUsers as $user) {
$this->db->insert('push_campaign_recipients', [
'campaign_id' => (int) $campaign['id'],
'user_id' => $user['id'],
'display_name' => $user['display_name'] ?? null,
'status' => 'pending',
]);
}
$sendResult = SupabasePushService::sendToUsers(
$allUserIds,
$campaign['title_ar'],
$campaign['body_ar'],
$campaign['notification_type'],
['campaign_id' => (int) $campaign['id']]
);
$this->db->update('push_campaigns', [
'status' => $sendResult['sent'] > 0 ? 'sent' : 'failed',
'sent_count' => $sendResult['sent'],
'failed_count' => $sendResult['failed'],
'sent_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $campaign['id']]);
if ($sendResult['sent'] > 0) {
$this->db->query(
"UPDATE push_campaign_recipients SET status = 'sent', sent_at = NOW() WHERE campaign_id = ?",
[(int) $campaign['id']]
);
$results['sent']++;
} else {
$results['failed']++;
}
} catch (\Throwable $e) {
Logger::error("PushNotificationCronJob: Campaign #{$campaign['id']} error: {$e->getMessage()}");
$this->db->update('push_campaigns', ['status' => 'failed'], 'id = ?', [(int) $campaign['id']]);
$results['failed']++;
}
}
if ($results['processed'] > 0) {
Logger::info("PushNotificationCronJob: processed={$results['processed']}, sent={$results['sent']}, failed={$results['failed']}");
}
return $results;
}
}
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS push_campaigns (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
title_ar VARCHAR(300) NOT NULL,
body_ar TEXT NOT NULL,
body_en TEXT NULL,
type ENUM('broadcast','demographic','individual') NOT NULL DEFAULT 'broadcast',
status ENUM('draft','sending','sent','scheduled','cancelled','failed') NOT NULL DEFAULT 'draft',
filters_json JSON NULL COMMENT 'Demographic filters used',
target_count INT UNSIGNED NOT NULL DEFAULT 0,
sent_count INT UNSIGNED NOT NULL DEFAULT 0,
failed_count INT UNSIGNED NOT NULL DEFAULT 0,
notification_type VARCHAR(50) NOT NULL DEFAULT 'announcement',
data_json JSON NULL COMMENT 'Extra payload data',
scheduled_at DATETIME NULL,
sent_at DATETIME NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_push_status (status),
KEY idx_push_scheduled (scheduled_at, status),
KEY idx_push_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS push_campaign_recipients (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
campaign_id BIGINT UNSIGNED NOT NULL,
user_id CHAR(36) NOT NULL COMMENT 'Supabase user UUID',
display_name VARCHAR(200) NULL,
status ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending',
error_message VARCHAR(500) NULL,
sent_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_recipient_campaign (campaign_id),
KEY idx_recipient_user (user_id),
KEY idx_recipient_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS push_templates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
title_ar VARCHAR(300) NOT NULL,
body_ar TEXT NOT NULL,
notification_type VARCHAR(50) NOT NULL DEFAULT 'announcement',
data_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO push_templates (name, title_ar, body_ar, notification_type) VALUES
('تحديث جديد', 'تحديث جديد! 🎮', 'تم إضافة ميزات جديدة للتطبيق، تعال شوف!', 'update'),
('بطولة جديدة', 'بطولة جديدة! 🏆', 'بطولة جديدة متاحة الآن، سجّل واربح جوائز!', 'tournament_start'),
('مكافأة يومية', 'مكافأتك اليومية جاهزة! 🎁', 'ادخل واستلم مكافأتك اليومية قبل ما تنتهي.', 'daily_reward'),
('عرض خاص', 'عرض خاص لفترة محدودة! 💎', 'خصم حصري في المتجر، لا تفوّت الفرصة!', 'promotion'),
('صيانة', 'صيانة مجدولة ⚙️', 'سيتم إجراء صيانة للخوادم. نعتذر عن أي إزعاج.', 'maintenance');
",
'down' => "
DROP TABLE IF EXISTS push_campaign_recipients;
DROP TABLE IF EXISTS push_campaigns;
DROP TABLE IF EXISTS push_templates;
",
];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment