Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
63605b9a
Commit
63605b9a
authored
Jun 21, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Revert "feat: add push notification management system for EL3AB mobile app"
This reverts commit
57e37bea
.
parent
57e37bea
Changes
19
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1 addition
and
2028 deletions
+1
-2028
.env
.env
+1
-4
CampaignController.php
...ules/PushNotifications/Controllers/CampaignController.php
+0
-110
ComposeController.php
...dules/PushNotifications/Controllers/ComposeController.php
+0
-164
IndividualController.php
...es/PushNotifications/Controllers/IndividualController.php
+0
-96
ScheduleController.php
...ules/PushNotifications/Controllers/ScheduleController.php
+0
-92
TemplateController.php
...ules/PushNotifications/Controllers/TemplateController.php
+0
-100
Routes.php
app/Modules/PushNotifications/Routes.php
+0
-32
SupabasePushService.php
...odules/PushNotifications/Services/SupabasePushService.php
+0
-293
campaign_detail.php
app/Modules/PushNotifications/Views/campaign_detail.php
+0
-116
campaigns.php
app/Modules/PushNotifications/Views/campaigns.php
+0
-105
compose.php
app/Modules/PushNotifications/Views/compose.php
+0
-203
individual.php
app/Modules/PushNotifications/Views/individual.php
+0
-152
scheduled.php
app/Modules/PushNotifications/Views/scheduled.php
+0
-87
template_form.php
app/Modules/PushNotifications/Views/template_form.php
+0
-55
templates.php
app/Modules/PushNotifications/Views/templates.php
+0
-53
bootstrap.php
app/Modules/PushNotifications/bootstrap.php
+0
-30
SupabaseService.php
app/Shared/Services/SupabaseService.php
+0
-143
PushNotificationCronJob.php
cron/jobs/PushNotificationCronJob.php
+0
-125
Phase_95_001_create_push_campaigns_tables.php
.../migrations/Phase_95_001_create_push_campaigns_tables.php
+0
-68
No files found.
.env
View file @
63605b9a
...
...
@@ -11,6 +11,3 @@ DB_PASS=Alarcade123#
SMS_PROVIDER=
SMS_API_KEY=
SMS_SENDER_ID=
\ No newline at end of file
SUPABASE_URL=https://safe-supabase-kong.caprover.al-arcade.com
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4
\ No newline at end of file
app/Modules/PushNotifications/Controllers/CampaignController.php
deleted
100644 → 0
View file @
57e37bea
<?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
(
'تم حذف الحملة'
);
}
}
app/Modules/PushNotifications/Controllers/ComposeController.php
deleted
100644 → 0
View file @
57e37bea
<?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
;
}
}
app/Modules/PushNotifications/Controllers/IndividualController.php
deleted
100644 → 0
View file @
57e37bea
<?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
(
'فشل إرسال الإشعار'
);
}
}
app/Modules/PushNotifications/Controllers/ScheduleController.php
deleted
100644 → 0
View file @
57e37bea
<?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
;
}
}
app/Modules/PushNotifications/Controllers/TemplateController.php
deleted
100644 → 0
View file @
57e37bea
<?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
(
'تم حذف القالب'
);
}
}
app/Modules/PushNotifications/Routes.php
deleted
100644 → 0
View file @
57e37bea
<?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'
],
];
app/Modules/PushNotifications/Services/SupabasePushService.php
deleted
100644 → 0
View file @
57e37bea
<?php
declare
(
strict_types
=
1
);
namespace
App\Modules\PushNotifications\Services
;
use
App\Core\App
;
use
App\Core\Logger
;
use
App\Shared\Services\SupabaseService
;
final
class
SupabasePushService
{
private
static
function
supa
()
:
SupabaseService
{
return
SupabaseService
::
getInstance
();
}
/**
* Query Supabase profiles with demographic filters.
* Returns array of ['id' => uuid, 'display_name' => string, ...]
*/
public
static
function
queryPlayers
(
array
$filters
,
int
$limit
=
1000
,
int
$offset
=
0
)
:
array
{
$params
=
[
'select'
=>
'id,display_name,level,xp,coins,games_played,total_wins,last_seen,created_at,country'
,
'order'
=>
'last_seen.desc'
,
'limit'
=>
$limit
,
'offset'
=>
$offset
,
];
// Level range
if
(
!
empty
(
$filters
[
'level_min'
]))
{
$params
[
'level'
]
=
'gte.'
.
(
int
)
$filters
[
'level_min'
];
}
if
(
!
empty
(
$filters
[
'level_max'
]))
{
$params
[
'level'
]
=
isset
(
$params
[
'level'
])
?
$params
[
'level'
]
// Can't do range in single param, use separate
:
'lte.'
.
(
int
)
$filters
[
'level_max'
];
}
// Handle both level_min and level_max
if
(
!
empty
(
$filters
[
'level_min'
])
&&
!
empty
(
$filters
[
'level_max'
]))
{
unset
(
$params
[
'level'
]);
$params
[
'and'
]
=
'(level.gte.'
.
(
int
)
$filters
[
'level_min'
]
.
',level.lte.'
.
(
int
)
$filters
[
'level_max'
]
.
')'
;
}
// Games played range
if
(
!
empty
(
$filters
[
'games_min'
]))
{
$params
[
'games_played'
]
=
'gte.'
.
(
int
)
$filters
[
'games_min'
];
}
// Coins range
if
(
!
empty
(
$filters
[
'coins_min'
]))
{
$params
[
'coins'
]
=
'gte.'
.
(
int
)
$filters
[
'coins_min'
];
}
if
(
!
empty
(
$filters
[
'coins_max'
]))
{
$params
[
'coins'
]
=
'lte.'
.
(
int
)
$filters
[
'coins_max'
];
}
// Activity filter
if
(
!
empty
(
$filters
[
'activity'
]))
{
$now
=
new
\DateTime
();
switch
(
$filters
[
'activity'
])
{
case
'active_today'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
format
(
'Y-m-d'
)
.
'T00:00:00'
;
break
;
case
'active_7d'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
modify
(
'-7 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'active_30d'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
modify
(
'-30 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_7d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-7 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_30d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-30 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_90d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-90 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
}
}
// Registration date
if
(
!
empty
(
$filters
[
'registered_after'
]))
{
$params
[
'created_at'
]
=
'gte.'
.
$filters
[
'registered_after'
]
.
'T00:00:00'
;
}
if
(
!
empty
(
$filters
[
'registered_before'
]))
{
$params
[
'created_at'
]
=
'lte.'
.
$filters
[
'registered_before'
]
.
'T23:59:59'
;
}
// Country
if
(
!
empty
(
$filters
[
'country'
]))
{
$params
[
'country'
]
=
'eq.'
.
$filters
[
'country'
];
}
$result
=
self
::
supa
()
->
get
(
'profiles'
,
$params
);
if
(
isset
(
$result
[
'error'
]))
{
Logger
::
error
(
"SupabasePushService::queryPlayers error: "
.
$result
[
'error'
]);
return
[];
}
return
$result
;
}
/**
* Count players matching filters (without fetching all data).
*/
public
static
function
countPlayers
(
array
$filters
)
:
int
{
$params
=
[
'select'
=>
'id'
];
if
(
!
empty
(
$filters
[
'level_min'
])
&&
!
empty
(
$filters
[
'level_max'
]))
{
$params
[
'and'
]
=
'(level.gte.'
.
(
int
)
$filters
[
'level_min'
]
.
',level.lte.'
.
(
int
)
$filters
[
'level_max'
]
.
')'
;
}
elseif
(
!
empty
(
$filters
[
'level_min'
]))
{
$params
[
'level'
]
=
'gte.'
.
(
int
)
$filters
[
'level_min'
];
}
elseif
(
!
empty
(
$filters
[
'level_max'
]))
{
$params
[
'level'
]
=
'lte.'
.
(
int
)
$filters
[
'level_max'
];
}
if
(
!
empty
(
$filters
[
'games_min'
]))
{
$params
[
'games_played'
]
=
'gte.'
.
(
int
)
$filters
[
'games_min'
];
}
if
(
!
empty
(
$filters
[
'coins_min'
]))
{
$params
[
'coins'
]
=
'gte.'
.
(
int
)
$filters
[
'coins_min'
];
}
if
(
!
empty
(
$filters
[
'coins_max'
]))
{
$params
[
'coins'
]
=
'lte.'
.
(
int
)
$filters
[
'coins_max'
];
}
if
(
!
empty
(
$filters
[
'activity'
]))
{
$now
=
new
\DateTime
();
switch
(
$filters
[
'activity'
])
{
case
'active_today'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
format
(
'Y-m-d'
)
.
'T00:00:00'
;
break
;
case
'active_7d'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
modify
(
'-7 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'active_30d'
:
$params
[
'last_seen'
]
=
'gte.'
.
$now
->
modify
(
'-30 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_7d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-7 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_30d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-30 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
case
'inactive_90d'
:
$params
[
'last_seen'
]
=
'lte.'
.
$now
->
modify
(
'-90 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
break
;
}
}
if
(
!
empty
(
$filters
[
'country'
]))
{
$params
[
'country'
]
=
'eq.'
.
$filters
[
'country'
];
}
return
self
::
supa
()
->
getCount
(
'profiles'
,
$params
);
}
/**
* Search players by name or ID for individual targeting.
*/
public
static
function
searchPlayers
(
string
$query
,
int
$limit
=
20
)
:
array
{
if
(
strlen
(
$query
)
<
2
)
return
[];
// Try UUID search first
if
(
preg_match
(
'/^[0-9a-f]{8}-/'
,
$query
))
{
$result
=
self
::
supa
()
->
get
(
'profiles'
,
[
'select'
=>
'id,display_name,level,last_seen,country,games_played'
,
'id'
=>
'eq.'
.
$query
,
'limit'
=>
1
,
]);
return
isset
(
$result
[
'error'
])
?
[]
:
$result
;
}
$result
=
self
::
supa
()
->
get
(
'profiles'
,
[
'select'
=>
'id,display_name,level,last_seen,country,games_played'
,
'display_name'
=>
'ilike.*'
.
$query
.
'*'
,
'order'
=>
'last_seen.desc'
,
'limit'
=>
$limit
,
]);
return
isset
(
$result
[
'error'
])
?
[]
:
$result
;
}
/**
* Send a push notification to a list of user IDs.
* Inserts rows into Supabase `notifications` table (triggers Realtime to mobile clients).
*/
public
static
function
sendToUsers
(
array
$userIds
,
string
$title
,
string
$body
,
string
$type
=
'announcement'
,
array
$data
=
[])
:
array
{
$results
=
[
'sent'
=>
0
,
'failed'
=>
0
,
'errors'
=>
[]];
// Batch in chunks of 50 for Supabase REST API
$chunks
=
array_chunk
(
$userIds
,
50
);
foreach
(
$chunks
as
$chunk
)
{
$rows
=
[];
foreach
(
$chunk
as
$userId
)
{
$rows
[]
=
[
'user_id'
=>
$userId
,
'type'
=>
$type
,
'title'
=>
$title
,
'title_ar'
=>
$title
,
'body'
=>
$body
,
'body_ar'
=>
$body
,
'data'
=>
!
empty
(
$data
)
?
json_encode
(
$data
)
:
null
,
];
}
$response
=
self
::
supa
()
->
insertBatch
(
'notifications'
,
$rows
);
if
(
isset
(
$response
[
'error'
]))
{
$results
[
'failed'
]
+=
count
(
$chunk
);
$results
[
'errors'
][]
=
$response
[
'error'
];
Logger
::
error
(
"SupabasePushService::sendToUsers batch error: "
.
$response
[
'error'
]);
}
else
{
$results
[
'sent'
]
+=
count
(
$chunk
);
}
}
return
$results
;
}
/**
* Send to a single user.
*/
public
static
function
sendToUser
(
string
$userId
,
string
$title
,
string
$body
,
string
$type
=
'announcement'
,
array
$data
=
[])
:
bool
{
$response
=
self
::
supa
()
->
insert
(
'notifications'
,
[
'user_id'
=>
$userId
,
'type'
=>
$type
,
'title'
=>
$title
,
'title_ar'
=>
$title
,
'body'
=>
$body
,
'body_ar'
=>
$body
,
'data'
=>
!
empty
(
$data
)
?
json_encode
(
$data
)
:
null
,
]);
if
(
isset
(
$response
[
'error'
]))
{
Logger
::
error
(
"SupabasePushService::sendToUser error for
{
$userId
}
: "
.
$response
[
'error'
]);
return
false
;
}
return
true
;
}
/**
* Get all players (for broadcast) in paginated fashion.
*/
public
static
function
getAllPlayerIds
(
int
$batchSize
=
1000
)
:
\Generator
{
$offset
=
0
;
while
(
true
)
{
$result
=
self
::
supa
()
->
get
(
'profiles'
,
[
'select'
=>
'id,display_name'
,
'order'
=>
'created_at.asc'
,
'limit'
=>
$batchSize
,
'offset'
=>
$offset
,
]);
if
(
isset
(
$result
[
'error'
])
||
empty
(
$result
))
{
break
;
}
yield
$result
;
if
(
count
(
$result
)
<
$batchSize
)
break
;
$offset
+=
$batchSize
;
}
}
/**
* Get player stats for dashboard display.
*/
public
static
function
getPlayerStats
()
:
array
{
$total
=
self
::
supa
()
->
getCount
(
'profiles'
,
[
'select'
=>
'id'
]);
$now
=
new
\DateTime
();
$today
=
$now
->
format
(
'Y-m-d'
)
.
'T00:00:00'
;
$week
=
(
clone
$now
)
->
modify
(
'-7 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
$month
=
(
clone
$now
)
->
modify
(
'-30 days'
)
->
format
(
'Y-m-d\TH:i:s'
);
$activeToday
=
self
::
supa
()
->
getCount
(
'profiles'
,
[
'select'
=>
'id'
,
'last_seen'
=>
'gte.'
.
$today
]);
$activeWeek
=
self
::
supa
()
->
getCount
(
'profiles'
,
[
'select'
=>
'id'
,
'last_seen'
=>
'gte.'
.
$week
]);
$activeMonth
=
self
::
supa
()
->
getCount
(
'profiles'
,
[
'select'
=>
'id'
,
'last_seen'
=>
'gte.'
.
$month
]);
return
[
'total'
=>
$total
,
'active_today'
=>
$activeToday
,
'active_week'
=>
$activeWeek
,
'active_month'
=>
$activeMonth
,
];
}
}
app/Modules/PushNotifications/Views/campaign_detail.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/Views/campaigns.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/Views/compose.php
deleted
100644 → 0
View file @
57e37bea
<?php
$__template
->
layout
(
'Layout.main'
);
?>
<?php
$__template
->
section
(
'title'
);
?>
إرسال إشعار جديد
<?php
$__template
->
endSection
();
?>
<?php
$__template
->
section
(
'content'
);
?>
<div
style=
"display:grid;grid-template-columns:2fr 1fr;gap:20px;"
>
<!-- Main form -->
<div>
<div
class=
"card"
style=
"padding:24px;margin-bottom:20px;"
>
<h4
style=
"color:#0D7377;margin:0 0 20px;"
>
محتوى الإشعار
</h4>
<form
id=
"push-form"
method=
"POST"
action=
"/push/compose/send"
>
<?=
csrf_field
()
?>
<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>
<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
style=
"font-size:11px;color:#9CA3AF;margin-top:4px;"
>
يظهر كعنوان الإشعار في هاتف اللاعب
</div>
</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
style=
"font-size:11px;color:#9CA3AF;margin-top:4px;"
>
الحد الأقصى 500 حرف
</div>
</div>
<div
class=
"form-group"
style=
"margin-bottom:16px;"
>
<label
class=
"form-label"
>
نوع الإشعار
</label>
<select
name=
"notification_type"
id=
"notification_type"
class=
"form-select"
>
<option
value=
"announcement"
>
إعلان عام
</option>
<option
value=
"update"
>
تحديث
</option>
<option
value=
"tournament_start"
>
بطولة
</option>
<option
value=
"daily_reward"
>
مكافأة يومية
</option>
<option
value=
"promotion"
>
عرض خاص
</option>
<option
value=
"maintenance"
>
صيانة
</option>
<option
value=
"match_invite"
>
دعوة لعب
</option>
<option
value=
"friend_online"
>
صديق متصل
</option>
</select>
</div>
<!-- Target type -->
<div
class=
"form-group"
style=
"margin-bottom:16px;"
>
<label
class=
"form-label"
>
المستهدفون
</label>
<div
style=
"display:flex;gap:10px;"
>
<label
style=
"display:flex;align-items:center;gap:6px;padding:10px 16px;border:2px solid #E5E7EB;border-radius:10px;cursor:pointer;"
id=
"lbl-broadcast"
>
<input
type=
"radio"
name=
"target_type"
value=
"broadcast"
checked
onchange=
"toggleFilters()"
>
<span
style=
"font-size:13px;font-weight:600;"
>
جميع اللاعبين
</span>
<span
style=
"font-size:11px;color:#6B7280;"
>
(
<?=
number_format
(
$stats
[
'total'
]
??
0
)
?>
)
</span>
</label>
<label
style=
"display:flex;align-items:center;gap:6px;padding:10px 16px;border:2px solid #E5E7EB;border-radius:10px;cursor:pointer;"
id=
"lbl-demographic"
>
<input
type=
"radio"
name=
"target_type"
value=
"demographic"
onchange=
"toggleFilters()"
>
<span
style=
"font-size:13px;font-weight:600;"
>
فئة محددة
</span>
</label>
</div>
</div>
<!-- Demographic filters -->
<div
id=
"filters-section"
style=
"display:none;background:#F9FAFB;border-radius:12px;padding:20px;margin-bottom:16px;"
>
<h5
style=
"margin:0 0 16px;color:#374151;font-size:14px;"
>
فلاتر الاستهداف
</h5>
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:12px;"
>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
المستوى (من)
</label>
<input
type=
"number"
name=
"level_min"
class=
"form-input"
min=
"0"
placeholder=
"0"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
المستوى (إلى)
</label>
<input
type=
"number"
name=
"level_max"
class=
"form-input"
min=
"0"
placeholder=
"∞"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
الألعاب (أقل حد)
</label>
<input
type=
"number"
name=
"games_min"
class=
"form-input"
min=
"0"
placeholder=
"0"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
النشاط
</label>
<select
name=
"activity"
class=
"form-select"
onchange=
"previewCount()"
>
<option
value=
""
>
الكل
</option>
<option
value=
"active_today"
>
نشط اليوم
</option>
<option
value=
"active_7d"
>
نشط آخر 7 أيام
</option>
<option
value=
"active_30d"
>
نشط آخر 30 يوم
</option>
<option
value=
"inactive_7d"
>
خامل +7 أيام
</option>
<option
value=
"inactive_30d"
>
خامل +30 يوم
</option>
<option
value=
"inactive_90d"
>
خامل +90 يوم
</option>
</select>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
العملات (من)
</label>
<input
type=
"number"
name=
"coins_min"
class=
"form-input"
min=
"0"
placeholder=
"0"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
العملات (إلى)
</label>
<input
type=
"number"
name=
"coins_max"
class=
"form-input"
min=
"0"
placeholder=
"∞"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
مسجّل بعد
</label>
<input
type=
"date"
name=
"registered_after"
class=
"form-input"
onchange=
"previewCount()"
>
</div>
<div>
<label
class=
"form-label"
style=
"font-size:12px;"
>
مسجّل قبل
</label>
<input
type=
"date"
name=
"registered_before"
class=
"form-input"
onchange=
"previewCount()"
>
</div>
<div
style=
"grid-column:1/-1;"
>
<label
class=
"form-label"
style=
"font-size:12px;"
>
البلد
</label>
<input
type=
"text"
name=
"country"
class=
"form-input"
placeholder=
"مثال: EG, SA, AE..."
onchange=
"previewCount()"
>
</div>
</div>
<div
id=
"preview-count"
style=
"margin-top:14px;padding:10px 14px;background:#EFF6FF;border-radius:8px;font-size:13px;color:#1D4ED8;display:none;"
>
عدد اللاعبين المطابقين:
<strong
id=
"count-value"
>
0
</strong>
</div>
</div>
<!-- Actions -->
<div
style=
"display:flex;gap:10px;margin-top:20px;"
>
<button
type=
"submit"
class=
"btn btn-primary"
onclick=
"return confirm('تأكيد إرسال الإشعار الآن؟')"
>
إرسال الآن
</button>
<button
type=
"button"
class=
"btn btn-outline"
onclick=
"showSchedule()"
>
جدولة
</button>
</div>
<!-- Schedule section (hidden) -->
<div
id=
"schedule-section"
style=
"display:none;margin-top:16px;padding:16px;background:#FEF3C7;border-radius:10px;"
>
<label
class=
"form-label"
style=
"font-size:12px;"
>
وقت الإرسال
</label>
<input
type=
"datetime-local"
name=
"scheduled_at"
id=
"scheduled_at"
class=
"form-input"
style=
"max-width:300px;"
>
<button
type=
"submit"
class=
"btn btn-primary"
style=
"margin-top:10px;"
formaction=
"/push/compose/schedule"
onclick=
"return confirm('تأكيد جدولة الإشعار؟')"
>
تأكيد الجدولة
</button>
</div>
</form>
</div>
</div>
<!-- Preview panel -->
<div>
<div
class=
"card"
style=
"padding:20px;position:sticky;top:20px;"
>
<h5
style=
"margin:0 0 16px;color:#374151;font-size:13px;"
>
معاينة الإشعار
</h5>
<div
style=
"background:#1F2937;border-radius:16px;padding:16px;color:#fff;"
>
<div
style=
"display:flex;align-items:center;gap:8px;margin-bottom:12px;"
>
<div
style=
"width:24px;height:24px;background:#10B981;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;"
>
EL
</div>
<span
style=
"font-size:11px;color:#9CA3AF;"
>
EL3AB • الآن
</span>
</div>
<div
id=
"preview-title"
style=
"font-size:13px;font-weight:700;margin-bottom:4px;direction:rtl;"
>
عنوان الإشعار
</div>
<div
id=
"preview-body"
style=
"font-size:12px;color:#D1D5DB;direction:rtl;line-height:1.5;"
>
نص الإشعار سيظهر هنا...
</div>
</div>
<div
style=
"margin-top:16px;font-size:11px;color:#9CA3AF;text-align:center;"
>
هذا تمثيل تقريبي لشكل الإشعار
</div>
</div>
</div>
</div>
<script>
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'
;
updatePreview
();
}
}
function
toggleFilters
()
{
const
isDemographic
=
document
.
querySelector
(
'input[name="target_type"][value="demographic"]'
).
checked
;
document
.
getElementById
(
'filters-section'
).
style
.
display
=
isDemographic
?
'block'
:
'none'
;
document
.
getElementById
(
'lbl-broadcast'
).
style
.
borderColor
=
isDemographic
?
'#E5E7EB'
:
'#0D7377'
;
document
.
getElementById
(
'lbl-demographic'
).
style
.
borderColor
=
isDemographic
?
'#0D7377'
:
'#E5E7EB'
;
}
function
updatePreview
()
{
document
.
getElementById
(
'preview-title'
).
textContent
=
document
.
getElementById
(
'title_ar'
).
value
||
'عنوان الإشعار'
;
document
.
getElementById
(
'preview-body'
).
textContent
=
document
.
getElementById
(
'body_ar'
).
value
||
'نص الإشعار سيظهر هنا...'
;
}
document
.
getElementById
(
'title_ar'
).
addEventListener
(
'input'
,
updatePreview
);
document
.
getElementById
(
'body_ar'
).
addEventListener
(
'input'
,
updatePreview
);
let
previewTimeout
;
function
previewCount
()
{
clearTimeout
(
previewTimeout
);
previewTimeout
=
setTimeout
(
async
()
=>
{
const
form
=
document
.
getElementById
(
'push-form'
);
const
data
=
new
FormData
(
form
);
try
{
const
resp
=
await
fetch
(
'/push/compose/preview'
,
{
method
:
'POST'
,
body
:
data
});
const
json
=
await
resp
.
json
();
document
.
getElementById
(
'count-value'
).
textContent
=
json
.
count
.
toLocaleString
();
document
.
getElementById
(
'preview-count'
).
style
.
display
=
'block'
;
}
catch
(
e
)
{}
},
500
);
}
function
showSchedule
()
{
const
section
=
document
.
getElementById
(
'schedule-section'
);
section
.
style
.
display
=
section
.
style
.
display
===
'none'
?
'block'
:
'none'
;
}
toggleFilters
();
</script>
<?php
$__template
->
endSection
();
?>
app/Modules/PushNotifications/Views/individual.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/Views/scheduled.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/Views/template_form.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/Views/templates.php
deleted
100644 → 0
View file @
57e37bea
<?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
();
?>
app/Modules/PushNotifications/bootstrap.php
deleted
100644 → 0
View file @
57e37bea
<?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'
],
]);
app/Shared/Services/SupabaseService.php
deleted
100644 → 0
View file @
57e37bea
<?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
??
[];
}
}
cron/jobs/PushNotificationCronJob.php
deleted
100644 → 0
View file @
57e37bea
<?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
;
}
}
database/migrations/Phase_95_001_create_push_campaigns_tables.php
deleted
100644 → 0
View file @
57e37bea
<?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;
"
,
];
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment