Commit 59a746e4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(codes): org invites and platform promo codes on redeem_codes

org-invites used org_invite_links: 8 lowercase hex characters, no link or
QR, hard deletes, roles the org model does not have, and nothing ever
redeemed one. It is rebuilt on redeem_codes, and a new global module,
/redeem-codes, does the same for platform promo codes. Both share
core/RedeemService.php and modules/org-invites/CodesBase.php:

- per-org (or platform) list with uses/max, reserved, link opens, previews
  and status (active, scheduled, expired, full, revoked), status and
  campaign filters;
- create through redeem_create with every grant type: org membership with
  a role, tournament entry (fee waiver), group join, coins and gems (from
  the org treasury or, for platform staff, minted), frame, shop item and
  badge; generated, numeric or vanity; window, max uses, approval,
  eligibility and welcome messages. The database's hard limits (staff
  codes, value codes, vanity codes) come back as Arabic messages; an
  org-scoped account creates as its org's owner;
- bulk: N single-use generated or numeric codes for a campaign through
  redeem_create_batch, and CSV export of a campaign's live codes for
  printed cards;
- usage page: link, QR, the funnel (redeem_code_funnel), every redemption
  with its source and new-signup flag, and reverse through redeem_reverse;
- soft revoke with a reason, logged in org_activity_log as invite.revoke;
- printable A4 poster (org, scan and join, QR at error level Q, the code,
  expiry);
- /redeem-codes/campaigns: every campaign across orgs and the platform
  with its funnel (redeem_campaign_stats).

Links are https://el3ab-player.caprover.al-arcade.com/r/<code>.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 42caf1c3
......@@ -54,6 +54,8 @@ return [
'org-media.*',
'org-spotlights.*',
'org-announcements.*',
'redeem-codes.*',
'game-content.*',
],
// ─── Moderation focused ──────────────────────────────────────────────
......
......@@ -261,9 +261,26 @@ return [
'organizations/{id}/invites' => ['module' => 'org-invites', 'action' => 'list'],
'organizations/{id}/invites/create' => ['module' => 'org-invites', 'action' => 'create'],
'organizations/{id}/invites/store' => ['module' => 'org-invites', 'action' => 'store'],
'organizations/{id}/invites/{inviteId}/toggle' => ['module' => 'org-invites', 'action' => 'toggle'],
'organizations/{id}/invites/{inviteId}/delete' => ['module' => 'org-invites', 'action' => 'delete'],
'organizations/{id}/invites/bulk' => ['module' => 'org-invites', 'action' => 'bulk'],
'organizations/{id}/invites/bulk/store' => ['module' => 'org-invites', 'action' => 'bulkStore'],
'organizations/{id}/invites/export' => ['module' => 'org-invites', 'action' => 'export'],
'organizations/{id}/invites/{inviteId}/usage' => ['module' => 'org-invites', 'action' => 'usage'],
'organizations/{id}/invites/{inviteId}/revoke' => ['module' => 'org-invites', 'action' => 'revoke'],
'organizations/{id}/invites/{inviteId}/poster' => ['module' => 'org-invites', 'action' => 'poster'],
'organizations/{id}/invites/{inviteId}/redemptions/{redemptionId}/reverse' => ['module' => 'org-invites', 'action' => 'reverse'],
// Platform promo codes and cross-org campaign reports
'redeem-codes' => ['module' => 'redeem-codes', 'action' => 'list'],
'redeem-codes/campaigns' => ['module' => 'redeem-codes', 'action' => 'campaigns'],
'redeem-codes/create' => ['module' => 'redeem-codes', 'action' => 'create'],
'redeem-codes/store' => ['module' => 'redeem-codes', 'action' => 'store'],
'redeem-codes/bulk' => ['module' => 'redeem-codes', 'action' => 'bulk'],
'redeem-codes/bulk/store' => ['module' => 'redeem-codes', 'action' => 'bulkStore'],
'redeem-codes/export' => ['module' => 'redeem-codes', 'action' => 'export'],
'redeem-codes/{codeId}/usage' => ['module' => 'redeem-codes', 'action' => 'usage'],
'redeem-codes/{codeId}/revoke' => ['module' => 'redeem-codes', 'action' => 'revoke'],
'redeem-codes/{codeId}/poster' => ['module' => 'redeem-codes', 'action' => 'poster'],
'redeem-codes/{codeId}/redemptions/{redemptionId}/reverse' => ['module' => 'redeem-codes', 'action' => 'reverse'],
// Org Membership Applications
'org-applications' => ['module' => 'org-applications', 'action' => 'list'],
......
<?php
/**
* Invite and promo codes (redeem_codes), for the manager.
*
* Every write that changes what a code does goes through the database
* functions (El3ab docs/redeem-contract.md 7.6, 7.7): redeem_create,
* redeem_create_batch and redeem_reverse. They check the creator's rights and
* the hard limits (staff codes generated / at most 5 uses / 7 days; value codes
* need max_uses and are never vanity; ...). The only direct writes are the
* ones the contract assigns to the caller: a soft revoke (PATCH revoked_*) and
* its org_activity_log row. uses, reserved and member_count are never written.
*/
class RedeemService
{
public const LINK_BASE = 'https://el3ab-player.caprover.al-arcade.com/r/';
public const STATUS = [
'active' => ['يعمل', 'success'],
'scheduled' => ['لم يبدأ', 'info'],
'expired' => ['انتهى', 'default'],
'full' => ['اكتمل', 'warning'],
'revoked' => ['ملغى', 'danger'],
];
public const SOURCES = [
'signup' => 'التسجيل', 'link' => 'رابط', 'qr' => 'QR', 'redeem_screen' => 'شاشة الكود',
'org_page' => 'صفحة المنظمة', 'guest_upgrade' => 'ترقية ضيف', 'admin' => 'الإدارة',
];
public const REDEMPTION_STATUS = [
'applied' => ['تم', 'success'], 'partial' => ['جزئي', 'warning'], 'pending_approval' => ['بانتظار الموافقة', 'info'],
'rejected' => ['مرفوض', 'danger'], 'reversed' => ['أُلغي', 'default'],
];
public const ROLES = ['member' => 'عضو', 'arbiter' => 'حكم', 'admin' => 'مشرف'];
/** Error keys of the functions (contract 6.2, 7.6, 7.7, 8), in staff Arabic. */
public const ERRORS = [
'creator_required' => 'حساب الإنشاء مطلوب؛ ادخل بحساب من حسابات الإدارة',
'forbidden' => 'لا تملك صلاحية إنشاء هذا الكود',
'invalid_scope' => 'نطاق غير صالح', 'org_required' => 'المنظمة مطلوبة', 'org_not_found' => 'المنظمة غير موجودة',
'org_inactive' => 'المنظمة موقوفة أو محذوفة', 'invalid_kind' => 'نوع الكود غير صالح',
'invalid_max_uses' => 'عدد الاستخدامات من 1 إلى 1,000,000', 'invalid_expiry' => 'تاريخ الانتهاء يجب أن يكون في المستقبل',
'invalid_window' => 'البداية يجب أن تسبق الانتهاء', 'invalid_text' => 'نص أطول من المسموح',
'invalid_eligibility' => 'شروط الأهلية غير صالحة', 'invalid_owner' => 'صاحب الكود ليس عضوًا نشطًا',
'invalid_grants' => 'اختر من 1 إلى 6 منح', 'invalid_grant' => 'إحدى المنح غير صالحة', 'invalid_role' => 'دور غير صالح',
'invalid_amount' => 'المبلغ من 1 إلى 1,000,000', 'invalid_count' => 'العدد من 1 إلى 1000',
'staff_code_must_be_generated' => 'كود يمنح دور حكم أو مشرف يجب أن يكون مولّدًا',
'staff_code_max_uses' => 'كود يمنح دور حكم أو مشرف: 5 استخدامات بحد أقصى',
'staff_code_expiry' => 'كود يمنح دور حكم أو مشرف: ينتهي خلال 7 أيام بحد أقصى',
'value_code_needs_max_uses' => 'كود فيه عملات أو جواهر أو إعفاء من الرسوم يحتاج عددًا أقصى للاستخدام',
'vanity_not_allowed' => 'الكود المخصص يمنح العضوية كعضو، أو مجموعة، أو بطولة مجانية فقط',
'approval_needs_one_org_join' => 'كود يحتاج موافقة يجب أن يمنح عضوية منظمة واحدة',
'invalid_format' => 'الكود المخصص: من 4 إلى 16 حرفًا أو رقمًا', 'code_blocked' => 'الكود المخصص يحتوي كلمة ممنوعة',
'code_taken' => 'الكود المخصص مستخدم بالفعل', 'code_space_exhausted' => 'تعذر توليد كود جديد، حاول مرة أخرى',
'actor_required' => 'الحساب مطلوب', 'not_found' => 'غير موجود', 'already_reversed' => 'أُلغي من قبل',
'not_reversible' => 'استخدام مرفوض لا يُلغى', 'player_not_found' => 'اللاعب غير موجود', 'account_required' => 'الحساب مطلوب',
];
public static function link(string $canonical): string
{
return self::LINK_BASE . rawurlencode($canonical);
}
/** revoked, scheduled, expired, full or active, in that order (redeem-api.md 5.1). */
public static function status(array $c): string
{
if (!empty($c['revoked_at'])) return 'revoked';
if (!empty($c['starts_at']) && strtotime($c['starts_at']) > time()) return 'scheduled';
if (!empty($c['expires_at']) && strtotime($c['expires_at']) <= time()) return 'expired';
if ($c['max_uses'] !== null && (int)$c['uses'] + (int)$c['reserved'] >= (int)$c['max_uses']) return 'full';
return 'active';
}
public static function message(?array $r): string
{
if (!is_array($r)) return 'خطأ في قاعدة البيانات: ' . Database::getInstance()->lastErrorMessage();
$key = (string)($r['error'] ?? '');
$msg = self::ERRORS[$key] ?? ('تعذر: ' . $key);
if (!empty($r['detail']) && is_string($r['detail'])) $msg .= ' (' . $r['detail'] . ')';
return $msg;
}
/** A short Arabic summary of a code's grants. */
public static function describeGrants($grants): string
{
if (is_string($grants)) $grants = json_decode($grants, true);
$parts = [];
foreach ((array)$grants as $g) {
$parts[] = match ($g['type'] ?? '') {
'org_join' => 'عضوية (' . (self::ROLES[$g['role'] ?? 'member'] ?? $g['role']) . ')',
'tournament_register' => 'تسجيل في بطولة' . (!empty($g['waive_fee']) ? ' بلا رسوم' : ''),
'group_join' => 'انضمام لمجموعة',
'coins' => number_format((int)($g['amount'] ?? 0)) . ' عملة' . (($g['source'] ?? '') === 'treasury' ? ' من الخزينة' : ''),
'gems' => number_format((int)($g['amount'] ?? 0)) . ' جوهرة' . (($g['source'] ?? '') === 'treasury' ? ' من الخزينة' : ''),
'frame' => 'إطار',
'cosmetic' => 'عنصر متجر',
'badge' => 'شارة',
default => (string)($g['type'] ?? '?'),
};
}
return implode('، ', $parts);
}
/**
* Read the create / bulk form into redeem_create arguments.
*
* @return array|string the arguments, or an error for staff
*/
public static function argsFromForm(?string $orgId, bool $bulk): array|string
{
$grants = [];
if (!empty($_POST['g_org_join']) && $orgId) {
$grants[] = ['type' => 'org_join', 'org_id' => $orgId, 'role' => (string)($_POST['g_role'] ?? 'member')];
}
if (($t = trim((string)($_POST['g_tournament_id'] ?? ''))) !== '') {
$grants[] = ['type' => 'tournament_register', 'tournament_id' => $t, 'waive_fee' => !empty($_POST['g_waive_fee'])];
}
if (($g = trim((string)($_POST['g_group_id'] ?? ''))) !== '') {
$grants[] = ['type' => 'group_join', 'group_id' => $g];
}
foreach (['coins', 'gems'] as $cur) {
$amount = (int)($_POST["g_{$cur}"] ?? 0);
if ($amount > 0) {
$source = $orgId && ($_POST["g_{$cur}_source"] ?? 'treasury') === 'treasury' ? 'treasury' : 'platform';
$grants[] = ['type' => $cur, 'amount' => $amount, 'source' => $source];
}
}
if (($f = trim((string)($_POST['g_frame_id'] ?? ''))) !== '') $grants[] = ['type' => 'frame', 'frame_id' => $f];
if (($c = trim((string)($_POST['g_cosmetic_id'] ?? ''))) !== '') $grants[] = ['type' => 'cosmetic', 'cosmetic_id' => $c];
if (($b = trim((string)($_POST['g_badge_id'] ?? ''))) !== '') $grants[] = ['type' => 'badge', 'achievement_id' => $b];
if (!$grants) return 'اختر منحة واحدة على الأقل';
$kind = (string)($_POST['kind'] ?? 'generated');
if ($bulk && !in_array($kind, ['generated', 'numeric'], true)) $kind = 'generated';
$maxRaw = trim((string)($_POST['max_uses'] ?? ''));
$maxUses = $maxRaw === '' ? null : (int)$maxRaw;
if ($bulk && $maxUses === null) $maxUses = 1;
$expires = Time::toUtc($_POST['expires_at'] ?? '');
if (($_POST['expires_at'] ?? '') !== '' && !$expires) return 'تاريخ الانتهاء غير صالح';
$starts = Time::toUtc($_POST['starts_at'] ?? '');
if (($_POST['starts_at'] ?? '') !== '' && !$starts) return 'تاريخ البداية غير صالح';
$elig = [];
if (!empty($_POST['e_new_accounts_only'])) $elig['new_accounts_only'] = true;
foreach (['min_level', 'min_games', 'account_age_days'] as $k) {
$v = trim((string)($_POST["e_{$k}"] ?? ''));
if ($v !== '') $elig[$k] = (int)$v;
}
$countries = array_values(array_filter(array_map(fn($x) => strtoupper(trim($x)), explode(',', (string)($_POST['e_countries'] ?? '')))));
if ($countries) $elig['countries'] = $countries;
$args = [
'p_grants' => $grants,
'p_scope' => $orgId ? 'org' : 'platform',
'p_org_id' => $orgId,
'p_kind' => $kind,
'p_label' => trim((string)($_POST['label'] ?? '')) ?: null,
'p_campaign' => trim((string)($_POST['campaign'] ?? '')) ?: null,
'p_max_uses' => $maxUses,
'p_starts_at' => $starts,
'p_expires_at' => $expires,
'p_requires_approval' => !empty($_POST['requires_approval']),
'p_eligibility' => $elig ?: new stdClass(),
'p_welcome_message' => trim((string)($_POST['welcome_message'] ?? '')) ?: null,
'p_welcome_message_ar' => trim((string)($_POST['welcome_message_ar'] ?? '')) ?: null,
'p_created_by_admin' => Auth::user()['id'] ?? null,
];
if ($bulk) {
$args['p_count'] = (int)($_POST['count'] ?? 0);
if (!$args['p_campaign']) return 'الحملة مطلوبة للتوليد الجماعي، لتجمع الأكواد وتصدّرها';
} elseif ($kind === 'vanity') {
$args['p_vanity'] = trim((string)($_POST['vanity'] ?? ''));
if ($args['p_vanity'] === '') return 'اكتب الكود المخصص';
}
return $args;
}
/** @return array{ok:bool, code?:array, codes?:array, count?:int, message?:string} */
public static function create(array $args): array
{
$db = Database::getInstance();
$fn = isset($args['p_count']) ? 'redeem_create_batch' : 'redeem_create';
$r = $db->rpc($fn, $args);
if (!is_array($r) || empty($r['ok'])) {
$db->clearWriteErrors();
return ['ok' => false, 'message' => self::message($r)];
}
return $r + ['ok' => true];
}
/**
* Soft revoke (contract 3.1: a PATCH; the API writes the activity row).
* Memberships already granted stay.
*/
public static function revoke(array $code, string $reason): array
{
$db = Database::getInstance();
if (!empty($code['revoked_at'])) return ['ok' => false, 'message' => 'الكود ملغى من قبل'];
$adminId = Auth::user()['id'] ?? null;
$row = $db->update('redeem_codes', ['id' => 'eq.' . $code['id'], 'revoked_at' => 'is.null'], [
'revoked_at' => gmdate('c'),
'revoked_by' => $adminId,
'revoke_reason' => mb_substr($reason, 0, 500),
]);
if ($row === null) return ['ok' => false, 'message' => 'لم يُلغَ: ' . $db->lastErrorMessage()];
if (empty($row)) return ['ok' => false, 'message' => 'الكود ملغى من قبل'];
if (!empty($code['org_id'])) {
$db->soft(fn(Database $d) => $d->insert('org_activity_log', [
'org_id' => $code['org_id'],
// actor_id is not null with no FK; for staff it holds the
// admin id, as redeem_create writes it.
'actor_id' => $adminId ?? '00000000-0000-0000-0000-000000000000',
'action' => 'invite.revoke',
'resource_type' => 'redeem_code',
'resource_id' => $code['id'],
'details' => ['reason' => $reason, 'by_admin' => $adminId, 'code' => $code['code_display']],
]));
}
return ['ok' => true];
}
/** Undo one redemption through redeem_reverse (contract 7.6). */
public static function reverse(string $redemptionId, string $reason): array
{
$db = Database::getInstance();
$adminId = Auth::user()['id'] ?? null;
if (!$adminId) return ['ok' => false, 'message' => self::ERRORS['actor_required']];
$r = $db->rpc('redeem_reverse', ['p_redemption' => $redemptionId, 'p_actor' => $adminId, 'p_reason' => $reason !== '' ? $reason : null]);
if (!is_array($r) || empty($r['ok'])) {
$db->clearWriteErrors();
return ['ok' => false, 'message' => self::message($r)];
}
return $r;
}
/** The per-code funnel (redeem_code_funnel, El3ab migrations/2026-10-26-manager-orgs.sql). */
public static function stats(string $codeId): array
{
$r = Database::getInstance()->rpc('redeem_code_funnel', ['p_code' => $codeId]);
return is_array($r) && !empty($r['ok']) ? $r : ['funnel' => [], 'by_day' => [], 'by_source' => []];
}
/** Redemptions of a code with their players, newest first. */
public static function redemptions(string $codeId, int $limit = 200): array
{
$db = Database::getInstance();
$rows = $db->select('redeem_redemptions', [
'select' => '*', 'code_id' => 'eq.' . $codeId, 'order' => 'created_at.desc', 'limit' => $limit,
]);
$ids = array_values(array_unique(array_column($rows, 'player_id')));
$players = [];
if ($ids) {
foreach ($db->select('profiles', ['select' => 'id,username,display_name,avatar_url,level', 'id' => 'in.(' . implode(',', $ids) . ')']) as $p) {
$players[$p['id']] = $p;
}
}
foreach ($rows as &$r) $r['player'] = $players[$r['player_id']] ?? null;
unset($r);
return $rows;
}
/** Send a CSV of codes for printing cards. */
public static function exportCsv(array $codes, string $filename): void
{
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9_.-]/', '_', $filename) . '"');
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($out, ['code', 'link', 'campaign', 'label', 'max_uses', 'uses', 'expires_at_cairo', 'status'], ',', '"', '');
foreach ($codes as $c) {
fputcsv($out, [
$c['code_display'], self::link($c['code']), $c['campaign'] ?? '', $c['label'] ?? '',
$c['max_uses'] ?? '', $c['uses'] ?? 0, Time::show($c['expires_at'] ?? null, 'Y-m-d H:i', ''), self::status($c),
], ',', '"', '');
}
fclose($out);
exit;
}
/**
* What a create form may offer: tournaments open for registration (the
* org's own for an org code), frames (the org's own for an org code),
* shop items and platform achievements.
*/
public static function formLists(?string $orgId): array
{
$db = Database::getInstance();
// An org-scoped account creates as the org's owner (contract 6.2): its
// own tournaments and frames, coins only from the treasury, no shop
// items or badges. Platform staff may grant anything.
$scoped = Auth::isOrgScoped();
$tq = ['select' => 'id,name,name_ar,status,entry_fee_coins,starts_at', 'status' => 'in.(draft,registration)', 'order' => 'starts_at.asc', 'limit' => 100];
if ($scoped) $tq['org_id'] = 'eq.' . Auth::orgId();
$fq = ['select' => 'id,name,name_ar,org_id', 'order' => 'name.asc', 'limit' => 200];
if ($scoped) $fq['org_id'] = 'eq.' . Auth::orgId();
return [
'staff' => !$scoped,
'tournaments' => $db->select('el3ab_tournaments', $tq),
'frames' => $db->select('profile_frames', $fq),
'cosmetics' => $scoped ? [] : $db->select('cosmetics', ['select' => 'id,name,name_ar,type,rarity', 'order' => 'name.asc']),
'achievements' => $scoped ? [] : $db->select('achievements', ['select' => 'id,name,name_ar', 'order' => 'tier.asc']),
];
}
}
......@@ -19,6 +19,7 @@ require_once __DIR__ . '/core/OrgPermissions.php';
require_once __DIR__ . '/core/OrgScope.php';
require_once __DIR__ . '/core/EconomyService.php';
require_once __DIR__ . '/core/ProofDocument.php';
require_once __DIR__ . '/core/RedeemService.php';
$route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD'];
......
......@@ -124,6 +124,10 @@ try { $pendingWorkflows = $isAdmin ? $db->count('approval_requests', ['status' =
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
<span class="nav-text">الإطارات</span>
</a>
<a href="/redeem-codes" class="nav-item <?= navActive('redeem-codes', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span class="nav-text">أكواد المنصة</span>
</a>
<a href="/org-applications" class="nav-item <?= navActive('org-applications', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
<span class="nav-text">طلبات العضوية</span>
......
<?php
/**
* The code screens shared by an org's invites (organizations/{id}/invites) and
* the platform's promo codes (/redeem-codes). Subclasses say which org (or
* none) they work on and where their pages live.
*/
abstract class CodesBase
{
protected Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/** [org row or null, base URL] for this request; 404s when the org is missing. */
abstract protected function context(array $params): array;
/** Extra filters for this list (an org's codes, or platform codes). */
abstract protected function scopeQuery(?array $org): array;
protected function render(string $view, array $data): void
{
View::render('org-invites/' . $view, $data);
}
public function list(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$status = (string)($_GET['status'] ?? '');
$campaign = trim((string)($_GET['campaign'] ?? ''));
$q = ['select' => '*', 'order' => 'created_at.desc', 'limit' => 500] + $this->scopeQuery($org);
if ($campaign !== '') $q['campaign'] = 'eq.' . $campaign;
$codes = $this->db->select('redeem_codes', $q);
foreach ($codes as &$c) $c['status'] = RedeemService::status($c);
unset($c);
$all = $codes;
if ($status !== '' && isset(RedeemService::STATUS[$status])) {
$codes = array_values(array_filter($codes, fn($c) => $c['status'] === $status));
}
$campaigns = array_values(array_unique(array_filter(array_column(
$this->db->select('redeem_codes', ['select' => 'campaign', 'campaign' => 'not.is.null', 'limit' => 2000] + $this->scopeQuery($org)),
'campaign'
))));
sort($campaigns);
$counts = array_count_values(array_column($all, 'status'));
$pageTitle = $org ? 'أكواد الدعوة - ' . $this->orgName($org) : 'أكواد المنصة';
$this->render('list', compact('org', 'base', 'codes', 'status', 'campaign', 'campaigns', 'counts', 'pageTitle'));
}
public function create(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$bulk = false;
$lists = RedeemService::formLists($org['id'] ?? null);
$pageTitle = 'كود جديد';
$this->render('form', compact('org', 'base', 'bulk', 'lists', 'pageTitle'));
}
public function bulk(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$bulk = true;
$lists = RedeemService::formLists($org['id'] ?? null);
$pageTitle = 'توليد أكواد لحملة';
$this->render('form', compact('org', 'base', 'bulk', 'lists', 'pageTitle'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
[$org, $base] = $this->context($params);
$this->guardOrgActive($org, $base);
$args = RedeemService::argsFromForm($org['id'] ?? null, false);
if (is_string($args)) {
Response::error($args, "{$base}/create");
return;
}
$r = RedeemService::create($args);
if (!$r['ok']) {
Response::error('لم يُنشأ الكود: ' . $r['message'], "{$base}/create");
return;
}
$code = $r['code'];
AuditLog::log('create', 'redeem_code', $code['id'], null, ['code' => $code['code_display'], 'org_id' => $org['id'] ?? null, 'grants' => $code['grants']]);
Response::success('تم إنشاء الكود ' . $code['code_display'], "{$base}/" . $code['id'] . '/usage');
}
public function bulkStore(array $params, string $method): void
{
Auth::requireCsrf();
[$org, $base] = $this->context($params);
$this->guardOrgActive($org, $base);
$args = RedeemService::argsFromForm($org['id'] ?? null, true);
if (is_string($args)) {
Response::error($args, "{$base}/bulk");
return;
}
$r = RedeemService::create($args);
if (!$r['ok']) {
Response::error('لم تُولَّد الأكواد: ' . $r['message'], "{$base}/bulk");
return;
}
AuditLog::log('bulk_create', 'redeem_code', null, null, ['count' => $r['count'] ?? 0, 'campaign' => $args['p_campaign'], 'org_id' => $org['id'] ?? null]);
Response::success('تم توليد ' . (int)($r['count'] ?? 0) . ' كود في حملة "' . $args['p_campaign'] . '". صدّرها من زر تصدير CSV', "{$base}?campaign=" . rawurlencode($args['p_campaign']));
}
public function export(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$campaign = trim((string)($_GET['campaign'] ?? ''));
$q = ['select' => '*', 'order' => 'created_at.asc', 'limit' => 5000] + $this->scopeQuery($org);
if ($campaign !== '') $q['campaign'] = 'eq.' . $campaign;
$codes = $this->db->select('redeem_codes', $q);
if (empty($_GET['all'])) $codes = array_values(array_filter($codes, fn($c) => RedeemService::status($c) === 'active'));
$name = ($org['slug'] ?? 'el3ab') . '-' . ($campaign !== '' ? $campaign : 'codes') . '-' . date('Ymd') . '.csv';
RedeemService::exportCsv($codes, $name);
}
public function usage(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$code = $this->code($params, $org, $base);
$code['status'] = RedeemService::status($code);
$stats = RedeemService::stats($code['id']);
$redemptions = RedeemService::redemptions($code['id']);
$creator = null;
if (!empty($code['created_by_admin'])) {
$a = $this->db->selectOne('admin_users', ['id' => 'eq.' . $code['created_by_admin'], 'select' => 'username,display_name']);
if ($a) $creator = ($a['display_name'] ?: $a['username']) . ' (الإدارة)';
} elseif (!empty($code['created_by_player'])) {
$p = $this->db->selectOne('profiles', ['id' => 'eq.' . $code['created_by_player'], 'select' => 'username,display_name']);
if ($p) $creator = $p['display_name'] ?: $p['username'];
}
$link = RedeemService::link($code['code']);
$qr = QRCodeService::generateSvg($link, 220, 'Q');
$pageTitle = 'الكود ' . $code['code_display'];
$this->render('usage', compact('org', 'base', 'code', 'stats', 'redemptions', 'creator', 'link', 'qr', 'pageTitle'));
}
public function revoke(array $params, string $method): void
{
Auth::requireCsrf();
[$org, $base] = $this->context($params);
$code = $this->code($params, $org, $base);
$reason = trim((string)($_POST['reason'] ?? ''));
if ($reason === '') {
Response::error('اكتب سبب الإلغاء', "{$base}/{$code['id']}/usage");
return;
}
$r = RedeemService::revoke($code, $reason);
if (!$r['ok']) {
Response::error($r['message'], "{$base}/{$code['id']}/usage");
return;
}
AuditLog::log('revoke', 'redeem_code', $code['id'], null, ['reason' => $reason, 'code' => $code['code_display']]);
Response::success('أُلغي الكود. العضويات والمنح التي تمت به تبقى؛ ألغِ كل استخدام على حدة إن لزم', "{$base}/{$code['id']}/usage");
}
public function reverse(array $params, string $method): void
{
Auth::requireCsrf();
[$org, $base] = $this->context($params);
$code = $this->code($params, $org, $base);
$redemptionId = (string)($params['redemptionId'] ?? '');
$own = $this->db->selectOne('redeem_redemptions', ['id' => 'eq.' . $redemptionId, 'code_id' => 'eq.' . $code['id'], 'select' => 'id,player_id']);
if (!$own) {
Response::error('الاستخدام غير موجود لهذا الكود', "{$base}/{$code['id']}/usage");
return;
}
$reason = trim((string)($_POST['reason'] ?? ''));
$r = RedeemService::reverse($redemptionId, $reason);
if (empty($r['ok'])) {
Response::error('لم يُلغَ الاستخدام: ' . ($r['message'] ?? ''), "{$base}/{$code['id']}/usage");
return;
}
AuditLog::log('reverse', 'redeem_redemption', $redemptionId, null, ['code' => $code['code_display'], 'reason' => $reason, 'effects' => $r['effects'] ?? []]);
Response::success('أُلغي الاستخدام وأُعيد ما منحه الكود (' . count($r['effects'] ?? []) . ' أثر)', "{$base}/{$code['id']}/usage");
}
/** A printable A4 poster: org, "scan and join", QR, the code, expiry. */
public function poster(array $params, string $method): void
{
[$org, $base] = $this->context($params);
$code = $this->code($params, $org, $base);
$link = RedeemService::link($code['code']);
$qr = QRCodeService::generateSvg($link, 520, 'Q');
$crownUrl = PlayerEngineService::baseUrl() . '/public/assets/game/crown.webp';
require MODULES_PATH . '/org-invites/views/poster.php';
exit;
}
protected function code(array $params, ?array $org, string $base): array
{
$id = (string)($params['inviteId'] ?? $params['codeId'] ?? '');
$q = ['id' => 'eq.' . $id] + $this->scopeQuery($org);
$code = preg_match('/^[0-9a-f-]{36}$/i', $id) ? $this->db->selectOne('redeem_codes', $q) : null;
if (!$code) {
Response::error('الكود غير موجود', $base);
exit;
}
return $code;
}
protected function guardOrgActive(?array $org, string $base): void
{
if ($org && empty($org['is_active'])) {
Response::error('المنظمة موقوفة؛ لا تُنشأ لها أكواد', $base);
exit;
}
}
protected function orgName(array $org): string
{
return (($org['name_ar'] ?? '') ?: $org['name']);
}
}
<?php
class OrgInvitesController
/**
* An org's invite codes, on redeem_codes (El3ab docs/redeem-contract.md 3.1,
* 7.7; design docs/org-invites-and-events-plan.md 2.13).
*
* This module used to write org_invite_links: 8 lowercase hex characters, no
* link or QR, hard deletes, roles the org model does not have, and nothing in
* the app ever redeemed one. That table is retired. Codes are now made by
* redeem_create / redeem_create_batch, revoked softly, and their uses can be
* undone one by one through redeem_reverse.
*/
require_once __DIR__ . '/CodesBase.php';
require_once MODULES_PATH . '/live-tournaments/services/QRCodeService.php';
require_once MODULES_PATH . '/tournaments/services/PlayerEngineService.php';
class OrgInvitesController extends CodesBase
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
protected function context(array $params): array
{
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
$orgId = (string)($params['id'] ?? '');
$org = preg_match('/^[0-9a-f-]{36}$/i', $orgId) ? $this->db->selectOne('el3ab_organizations', ['id' => 'eq.' . $orgId]) : null;
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$invites = $this->db->select('org_invite_links', [
'select' => '*',
'org_id' => "eq.{$orgId}",
'order' => 'created_at.desc',
]);
// Get usage counts for each invite
foreach ($invites as &$invite) {
$invite['uses_count'] = $this->db->count('org_invite_uses', [
'invite_link_id' => "eq.{$invite['id']}",
]);
}
unset($invite);
$pageTitle = 'روابط الدعوة - ' . ($org['name_ar'] ?? $org['name']);
View::render('org-invites/list', compact('org', 'invites', 'pageTitle'));
}
public function create(array $params, string $method): void
{
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$pageTitle = 'إنشاء رابط دعوة - ' . ($org['name_ar'] ?? $org['name']);
View::render('org-invites/form', compact('org', 'pageTitle'));
}
public function store(array $params, string $method): void
{
Auth::requireCsrf();
$orgId = $params['id'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
Response::error('المنظمة غير موجودة', '/organizations');
return;
}
$user = Auth::user();
$code = bin2hex(random_bytes(4));
$data = [
'org_id' => $orgId,
'code' => $code,
'created_by' => $user['id'],
'max_uses' => !empty($_POST['max_uses']) ? (int) $_POST['max_uses'] : null,
'expires_at' => !empty($_POST['expires_at']) ? $_POST['expires_at'] : null,
'requires_approval' => isset($_POST['requires_approval']) ? true : false,
'target_role' => $_POST['target_role'] ?? 'member',
'welcome_message' => trim($_POST['welcome_message'] ?? ''),
'welcome_message_ar' => trim($_POST['welcome_message_ar'] ?? ''),
'is_active' => true,
];
$result = $this->db->insert('org_invite_links', $data);
AuditLog::log('create', 'org_invite_link', $result['id'] ?? null, null, $data);
Response::success('تم إنشاء رابط الدعوة بنجاح', "/organizations/{$orgId}/invites");
}
public function toggle(array $params, string $method): void
{
Auth::requireCsrf();
$orgId = $params['id'];
$inviteId = $params['inviteId'];
$invite = $this->db->selectOne('org_invite_links', [
'id' => "eq.{$inviteId}",
'org_id' => "eq.{$orgId}",
]);
if (!$invite) {
Response::error('رابط الدعوة غير موجود', "/organizations/{$orgId}/invites");
return;
exit;
}
$newStatus = !($invite['is_active'] ?? false);
$this->db->update('org_invite_links', ['id' => "eq.{$inviteId}"], [
'is_active' => $newStatus,
]);
AuditLog::log('toggle', 'org_invite_link', $inviteId, $invite, ['is_active' => $newStatus]);
Response::success(
$newStatus ? 'تم تفعيل رابط الدعوة' : 'تم تعطيل رابط الدعوة',
"/organizations/{$orgId}/invites"
);
return [$org, '/organizations/' . $org['id'] . '/invites'];
}
public function delete(array $params, string $method): void
protected function scopeQuery(?array $org): array
{
Auth::requireCsrf();
$orgId = $params['id'];
$inviteId = $params['inviteId'];
$invite = $this->db->selectOne('org_invite_links', [
'id' => "eq.{$inviteId}",
'org_id' => "eq.{$orgId}",
]);
if (!$invite) {
Response::error('رابط الدعوة غير موجود', "/organizations/{$orgId}/invites");
return;
}
$this->db->delete('org_invite_links', ['id' => "eq.{$inviteId}"]);
AuditLog::log('delete', 'org_invite_link', $inviteId, $invite, null);
Response::success('تم حذف رابط الدعوة', "/organizations/{$orgId}/invites");
}
public function usage(array $params, string $method): void
{
$orgId = $params['id'];
$inviteId = $params['inviteId'];
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]);
if (!$org) {
http_response_code(404);
View::render('errors/404');
return;
}
$invite = $this->db->selectOne('org_invite_links', [
'id' => "eq.{$inviteId}",
'org_id' => "eq.{$orgId}",
]);
if (!$invite) {
Response::error('رابط الدعوة غير موجود', "/organizations/{$orgId}/invites");
return;
}
$uses = $this->db->select('org_invite_uses', [
'select' => '*, profiles(username, display_name)',
'invite_link_id' => "eq.{$inviteId}",
'order' => 'used_at.desc',
]);
$pageTitle = 'استخدام رابط الدعوة - ' . $invite['code'];
View::render('org-invites/usage', compact('org', 'invite', 'uses', 'pageTitle'));
return ['org_id' => 'eq.' . $org['id']];
}
}
<?php
/**
* Create one code, or (bulk) N single-use codes for a campaign.
* Expects: $org (null for a platform code), $base, $bulk, $lists
*/
$isOrg = $org !== null;
$staff = $lists['staff'];
$action = $base . ($bulk ? '/bulk/store' : '/store');
$orgLabel = $isOrg ? ((($org['name_ar'] ?? '') ?: $org['name'])) : null;
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations/<?= $org['id'] ?>/invites" class="btn btn-icon btn-ghost">
<a href="<?= $base ?>" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>إنشاء رابط دعوة - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<h1><?= $bulk ? 'توليد أكواد لحملة' : 'كود جديد' ?><?= $isOrg ? ' — ' . View::e($orgLabel) : ' للمنصة' ?></h1>
</div>
</div>
<div class="card max-w-lg">
<form method="POST" action="/organizations/<?= $org['id'] ?>/invites/store" data-validate>
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="card mb-4 p-4">
<p class="text-sm font-medium mb-2">حدود ثابتة تطبقها قاعدة البيانات:</p>
<ul class="text-sm text-secondary" style="padding-inline-start: 18px; list-style: disc;">
<li>كود يمنح دور حكم أو مشرف: مولّد فقط، 5 استخدامات بحد أقصى، وينتهي خلال 7 أيام.</li>
<li>كود فيه عملات أو جواهر أو إعفاء من رسوم البطولة: يحتاج عددًا أقصى للاستخدام، ولا يكون كودًا مخصصًا.</li>
<li>الكود المخصص يمنح فقط: العضوية كعضو، أو مجموعة، أو التسجيل في بطولة مجانية.</li>
<li>كود بموافقة: يمنح عضوية منظمة واحدة.</li>
<?php if ($isOrg && !$staff): ?><li>العملات والجواهر في كود منظمة تُدفع من خزينتها.</li><?php endif; ?>
</ul>
</div>
<div class="form-group">
<label class="form-label">الحد الأقصى للاستخدام</label>
<input type="number" name="max_uses" class="form-input" min="1" placeholder="اتركه فارغاً لاستخدام غير محدود">
<span class="form-hint">عدد المرات التي يمكن فيها استخدام هذا الرابط. اتركه فارغاً للسماح باستخدام غير محدود.</span>
</div>
<form method="POST" action="<?= $action ?>" class="grid grid-2 gap-5" style="align-items: start;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="card">
<h3 class="card-title mb-4">ماذا يمنح الكود</h3>
<?php if ($isOrg): ?>
<div class="form-group">
<label class="form-label">تاريخ انتهاء الصلاحية</label>
<input type="datetime-local" name="expires_at" class="form-input" dir="ltr">
<span class="form-hint">اتركه فارغاً لرابط بدون انتهاء صلاحية.</span>
<label class="flex items-center gap-2"><input type="checkbox" name="g_org_join" value="1" checked> <span>عضوية <?= View::e($orgLabel) ?></span></label>
<select name="g_role" class="form-select mt-2" aria-label="الدور">
<?php foreach (RedeemService::ROLES as $k => $l): ?>
<option value="<?= $k ?>"><?= $l ?></option>
<?php endforeach; ?>
</select>
<span class="form-hint">الكود لا يمنح دور المالك أبدًا ولا ينزّل دور عضو حالي.</span>
</div>
<?php endif; ?>
<div class="form-group">
<label class="form-label">الدور المستهدف</label>
<select name="target_role" class="form-input">
<option value="member">عضو</option>
<option value="moderator">مشرف</option>
<option value="admin">مدير</option>
<label class="form-label">تسجيل في بطولة</label>
<select name="g_tournament_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($lists['tournaments'] as $t): ?>
<option value="<?= View::e($t['id']) ?>"><?= View::e((($t['name_ar'] ?? '') ?: $t['name']) . ' — ' . Time::show($t['starts_at'] ?? null, 'Y/m/d') . ((int)($t['entry_fee_coins'] ?? 0) > 0 ? ' — رسوم ' . (int)$t['entry_fee_coins'] : '')) ?></option>
<?php endforeach; ?>
</select>
<span class="form-hint">الدور الذي سيحصل عليه المستخدم عند الانضمام عبر هذا الرابط.</span>
<label class="flex items-center gap-2 mt-2 text-sm"><input type="checkbox" name="g_waive_fee" value="1"> <span>إعفاء من رسوم البطولة</span></label>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="requires_approval" value="1">
<span class="checkbox-mark"></span>
<span class="checkbox-label">يتطلب موافقة المدير</span>
</label>
<span class="form-hint">إذا كان مفعلاً، سيحتاج المدير للموافقة على الانضمام بعد استخدام الرابط.</span>
<label class="form-label">انضمام لمجموعة (معرف المجموعة)</label>
<input type="text" name="g_group_id" class="form-input" dir="ltr" placeholder="uuid">
</div>
<div class="grid grid-2 gap-3">
<?php foreach (['coins' => 'عملات', 'gems' => 'جواهر'] as $cur => $curLabel): ?>
<div class="form-group">
<label class="form-label"><?= $curLabel ?></label>
<input type="number" name="g_<?= $cur ?>" class="form-input" min="0" max="1000000" placeholder="0">
<?php if ($isOrg && $staff): ?>
<select name="g_<?= $cur ?>_source" class="form-select mt-2" aria-label="مصدر <?= $curLabel ?>">
<option value="treasury">من خزينة المنظمة</option>
<option value="platform">من المنصة</option>
</select>
<?php elseif ($isOrg): ?>
<input type="hidden" name="g_<?= $cur ?>_source" value="treasury">
<span class="form-hint">من خزينة المنظمة</span>
<?php else: ?>
<span class="form-hint">تُصرف من المنصة</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="form-group">
<label class="form-label">رسالة الترحيب (English)</label>
<textarea name="welcome_message" class="form-input" rows="3" placeholder="Welcome message for new members..." dir="ltr"></textarea>
<label class="form-label">إطار</label>
<select name="g_frame_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($lists['frames'] as $f): ?>
<option value="<?= View::e($f['id']) ?>"><?= View::e((($f['name_ar'] ?? '') ?: $f['name'])) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($staff): ?>
<div class="form-group">
<label class="form-label">عنصر من المتجر</label>
<select name="g_cosmetic_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($lists['cosmetics'] as $c): ?>
<option value="<?= View::e($c['id']) ?>"><?= View::e((($c['name_ar'] ?? '') ?: $c['name']) . ' — ' . $c['type']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">رسالة الترحيب (عربي)</label>
<textarea name="welcome_message_ar" class="form-input" rows="3" placeholder="رسالة ترحيب للأعضاء الجدد..."></textarea>
<label class="form-label">شارة (إنجاز)</label>
<select name="g_badge_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($lists['achievements'] as $a): ?>
<option value="<?= View::e($a['id']) ?>"><?= View::e((($a['name_ar'] ?? '') ?: $a['name'])) ?></option>
<?php endforeach; ?>
</select>
<span class="form-hint">الشارة تُمنح بدون مكافأة الإنجاز نفسه.</span>
</div>
<?php endif; ?>
</div>
<div class="flex flex-col gap-5">
<div class="card">
<h3 class="card-title mb-4">الكود</h3>
<?php if ($bulk): ?>
<div class="grid grid-2 gap-3">
<div class="form-group">
<label class="form-label">عدد الأكواد *</label>
<input type="number" name="count" class="form-input" min="1" max="1000" value="50" required>
</div>
<div class="form-group">
<label class="form-label">النوع</label>
<select name="kind" class="form-select">
<option value="generated">مولّد XXXX-XXXX</option>
<option value="numeric">رقمي 10 أرقام (كروت)</option>
</select>
</div>
</div>
<?php else: ?>
<div class="form-group">
<label class="form-label">النوع</label>
<select name="kind" class="form-select" onchange="document.getElementById('vanityBox').style.display = this.value === 'vanity' ? 'block' : 'none'">
<option value="generated">مولّد XXXX-XXXX</option>
<option value="numeric">رقمي 10 أرقام</option>
<option value="vanity">مخصص (مثل AHLY26 أو نادي-الزمالك)</option>
</select>
</div>
<div class="form-group" id="vanityBox" style="display:none;">
<label class="form-label">الكود المخصص</label>
<input type="text" name="vanity" class="form-input" maxlength="40" placeholder="AHLY26">
<span class="form-hint">من 4 إلى 16 حرفًا أو رقمًا بعد التنظيف؛ العربية مسموحة. الحروف المتشابهة (O و 0، S و 5) تُعامل كحرف واحد.</span>
</div>
<?php endif; ?>
<div class="grid grid-2 gap-3">
<div class="form-group">
<label class="form-label">الحملة<?= $bulk ? ' *' : '' ?></label>
<input type="text" name="campaign" class="form-input" maxlength="64" dir="ltr" placeholder="cairo-open-2026"<?= $bulk ? ' required' : '' ?>>
</div>
<div class="form-group">
<label class="form-label">وصف داخلي</label>
<input type="text" name="label" class="form-input" maxlength="120" placeholder="ملصق القاعة">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<span class="btn-text">إنشاء رابط الدعوة</span>
<span class="btn-spinner"></span>
</button>
<a href="/organizations/<?= $org['id'] ?>/invites" class="btn btn-ghost">إلغاء</a>
<div class="grid grid-2 gap-3">
<div class="form-group">
<label class="form-label">أقصى عدد استخدامات<?= $bulk ? ' (لكل كود)' : '' ?></label>
<input type="number" name="max_uses" class="form-input" min="1" max="1000000" value="<?= $bulk ? '1' : '' ?>" placeholder="بلا حد">
</div>
<?php if ($isOrg && !$bulk): ?>
<label class="flex items-center gap-2 text-sm" style="padding-top: 28px;">
<input type="checkbox" name="requires_approval" value="1">
<span>يحتاج موافقة إدارة المنظمة</span>
</label>
<?php endif; ?>
</div>
<div class="grid grid-2 gap-3">
<div class="form-group">
<label class="form-label">يبدأ (القاهرة)</label>
<input type="datetime-local" name="starts_at" class="form-input" dir="ltr">
</div>
<div class="form-group">
<label class="form-label">ينتهي (القاهرة)</label>
<input type="datetime-local" name="expires_at" class="form-input" dir="ltr">
</div>
</div>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title mb-4">من يمكنه استخدامه</h3>
<label class="flex items-center gap-2 text-sm mb-3"><input type="checkbox" name="e_new_accounts_only" value="1"> <span>الحسابات الجديدة فقط (بعد إنشاء الكود)</span></label>
<div class="grid grid-3 gap-3">
<div class="form-group"><label class="form-label">أدنى مستوى</label><input type="number" name="e_min_level" class="form-input" min="1"></div>
<div class="form-group"><label class="form-label">أدنى عدد مباريات</label><input type="number" name="e_min_games" class="form-input" min="0"></div>
<div class="form-group"><label class="form-label">عمر الحساب (أيام)</label><input type="number" name="e_account_age_days" class="form-input" min="0"></div>
</div>
<div class="form-group">
<label class="form-label">الدول (رموز مفصولة بفواصل)</label>
<input type="text" name="e_countries" class="form-input" dir="ltr" placeholder="EG,SA">
</div>
</div>
<div class="card">
<h3 class="card-title mb-4">رسالة الترحيب</h3>
<div class="form-group"><label class="form-label">بالعربية</label><textarea name="welcome_message_ar" class="form-input" rows="2" maxlength="1000"></textarea></div>
<div class="form-group"><label class="form-label">بالإنجليزية</label><textarea name="welcome_message" class="form-input" rows="2" maxlength="1000" dir="ltr"></textarea></div>
</div>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary"><?= $bulk ? 'توليد الأكواد' : 'إنشاء الكود' ?></button>
<a href="<?= $base ?>" class="btn btn-ghost">إلغاء</a>
</div>
</div>
</form>
<?php
/**
* Codes of one org ($org) or of the platform ($org null).
* Expects: $org, $base, $codes, $status, $campaign, $campaigns, $counts
*/
$title = $org ? 'أكواد الدعوة: ' . ((($org['name_ar'] ?? '') ?: $org['name'])) : 'أكواد المنصة';
$q = fn(array $over) => $base . '?' . http_build_query(array_filter(array_merge(['status' => $status, 'campaign' => $campaign], $over), fn($v) => $v !== '' && $v !== null));
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost">
<?php if ($org): ?>
<a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>روابط الدعوة - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1>
<?php endif; ?>
<h1><?= View::e($title) ?></h1>
</div>
<a href="/organizations/<?= $org['id'] ?>/invites/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
إنشاء رابط دعوة
<div class="flex gap-2">
<a href="<?= $base ?>/bulk" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
توليد لحملة
</a>
<a href="<?= $base ?>/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
كود جديد
</a>
</div>
</div>
<?php if (!$org): ?>
<div class="tabs mb-5">
<a href="/redeem-codes" class="tab active">الأكواد</a>
<a href="/redeem-codes/campaigns" class="tab">الحملات والقمع</a>
</div>
<?php endif; ?>
<?php if ($org && empty($org['is_active'])): ?>
<div class="card mb-4 p-4"><p class="text-sm text-danger">المنظمة موقوفة: كل أكوادها لا تعمل ولا تُنشأ أكواد جديدة.</p></div>
<?php endif; ?>
<div class="flex items-center gap-4 mb-5 flex-wrap">
<div class="filter-pills">
<a href="<?= View::e($q(['status' => ''])) ?>" class="filter-pill <?= $status === '' ? 'active' : '' ?>">الكل</a>
<?php foreach (RedeemService::STATUS as $k => [$label]): ?>
<a href="<?= View::e($q(['status' => $k])) ?>" class="filter-pill <?= $status === $k ? 'active' : '' ?>"><?= $label ?> <span class="tabular-nums">(<?= (int)($counts[$k] ?? 0) ?>)</span></a>
<?php endforeach; ?>
</div>
<?php if ($campaigns): ?>
<select class="form-select" style="width: auto;" aria-label="الحملة" onchange="location.href='<?= View::e($base) ?>?campaign='+encodeURIComponent(this.value)">
<option value="">كل الحملات</option>
<?php foreach ($campaigns as $c): ?>
<option value="<?= View::e($c) ?>" <?= $campaign === $c ? 'selected' : '' ?>><?= View::e($c) ?></option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<a href="<?= View::e($base . '/export?' . http_build_query(['campaign' => $campaign])) ?>" class="btn btn-ghost btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
تصدير CSV <?= $campaign !== '' ? 'للحملة' : '' ?> (الأكواد العاملة)
</a>
</div>
<div class="data-table-wrapper">
<?php if (empty($invites)): ?>
<?php if (empty($codes)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<h3 class="empty-state-title">لا توجد روابط دعوة</h3>
<p class="empty-state-text">لم يتم إنشاء أي روابط دعوة لهذه المنظمة بعد</p>
<h3 class="empty-state-title">لا توجد أكواد</h3>
<p class="empty-state-text">أنشئ كودًا ليشاركه اللاعبون رابطًا أو QR أو كارتًا مطبوعًا</p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>الكود</th>
<th>الدور المستهدف</th>
<th>الاستخدام</th>
<th>انتهاء الصلاحية</th>
<th>الحالة</th>
<th>تاريخ الإنشاء</th>
<th style="width: 60px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($invites as $invite): ?>
<tr>
<td>
<div class="flex items-center gap-2">
<code class="invite-code" style="font-family: monospace; font-size: 0.9rem; background: var(--bg-secondary); padding: 2px 8px; border-radius: 4px;"><?= View::e($invite['code']) ?></code>
<button type="button" class="btn btn-icon btn-ghost btn-sm" onclick="copyToClipboard('<?= View::e($invite['code']) ?>')" title="نسخ الكود">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</td>
<td>
<?php
$roleLabels = ['member' => 'عضو', 'moderator' => 'مشرف', 'admin' => 'مدير'];
$role = $invite['target_role'] ?? 'member';
?>
<span class="badge badge-default"><?= $roleLabels[$role] ?? $role ?></span>
</td>
<td>
<?php
$usesCount = $invite['uses_count'] ?? 0;
$maxUses = $invite['max_uses'] ?? null;
?>
<span><?= $usesCount ?> / <?= $maxUses ? $maxUses : '&#8734;' ?></span>
</td>
<td>
<?php if (!empty($invite['expires_at'])): ?>
<?php
$expiresAt = strtotime($invite['expires_at']);
$isExpired = $expiresAt < time();
?>
<span class="<?= $isExpired ? 'text-danger' : '' ?>">
<?= date('Y-m-d H:i', $expiresAt) ?>
</span>
<?php else: ?>
<span class="text-muted">بدون انتهاء</span>
<?php endif; ?>
</td>
<td>
<?php if ($invite['is_active'] ?? false): ?>
<span class="badge badge-success badge-dot">نشط</span>
<?php else: ?>
<span class="badge badge-danger badge-dot">معطل</span>
<?php endif; ?>
</td>
<td>
<?= date('Y-m-d', strtotime($invite['created_at'])) ?>
</td>
<td>
<div class="dropdown">
<button class="btn btn-icon btn-ghost" onclick="toggleDropdown(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
<div class="dropdown-menu">
<a href="/organizations/<?= $org['id'] ?>/invites/<?= $invite['id'] ?>/usage" class="dropdown-item">الاستخدام</a>
<form method="POST" action="/organizations/<?= $org['id'] ?>/invites/<?= $invite['id'] ?>/toggle" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="dropdown-item">
<?= ($invite['is_active'] ?? false) ? 'تعطيل' : 'تفعيل' ?>
</button>
</form>
<div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/invites/<?= $invite['id'] ?>/delete', '<?= View::e($invite['code']) ?>')">حذف</button>
</div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<table class="data-table">
<thead>
<tr>
<th>الكود</th>
<th>يمنح</th>
<th>الاستخدام</th>
<th>محجوز</th>
<th>فتح الرابط</th>
<th>معاينات</th>
<th>الحالة</th>
<th>ينتهي</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($codes as $c): ?>
<?php [$sl, $sb] = RedeemService::STATUS[$c['status']]; ?>
<tr>
<td>
<a href="<?= $base ?>/<?= $c['id'] ?>/usage" class="font-medium text-link"><bdi dir="ltr" style="font-family: monospace;"><?= View::e($c['code_display']) ?></bdi></a>
<?php if ($c['label'] || $c['campaign']): ?>
<div class="text-xs text-muted"><?= View::e(trim(($c['label'] ?? '') . ($c['campaign'] ? ' — ' . $c['campaign'] : ''), ' —')) ?></div>
<?php endif; ?>
</td>
<td class="text-sm"><?= View::e(RedeemService::describeGrants($c['grants'])) ?><?= !empty($c['requires_approval']) ? ' <span class="badge badge-info badge-sm">بموافقة</span>' : '' ?></td>
<td class="tabular-nums"><?= (int)$c['uses'] ?> / <?= $c['max_uses'] === null ? 'بلا حد' : (int)$c['max_uses'] ?></td>
<td class="tabular-nums"><?= (int)$c['reserved'] ?></td>
<td class="tabular-nums"><?= (int)$c['link_opens'] ?></td>
<td class="tabular-nums"><?= (int)$c['previews'] ?></td>
<td><span class="badge badge-<?= $sb ?> badge-sm"><?= $sl ?></span></td>
<td class="text-xs tabular-nums"><?= Time::show($c['expires_at'] ?? null, 'Y/m/d H:i', 'لا ينتهي') ?></td>
<td>
<div class="flex gap-2">
<a href="<?= $base ?>/<?= $c['id'] ?>/usage" class="btn btn-xs btn-ghost">الاستخدام</a>
<a href="<?= $base ?>/<?= $c['id'] ?>/poster" target="_blank" rel="noopener" class="btn btn-xs btn-ghost">QR وملصق</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
showToast('تم نسخ الكود بنجاح', 'success');
}).catch(function() {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('تم نسخ الكود بنجاح', 'success');
});
}
</script>
<?php
/**
* A4 portrait poster for a code (design 2.5): the org, "scan and join", the QR
* (error correction Q, dark on white, 4-module quiet zone), the code in display
* type, the expiry date and the El3ab crown. A standalone page so it prints
* without the manager's chrome; the screen view adds a print button.
* Expects: $org, $code, $link, $qr, $crownUrl
*/
$orgName = $org ? ((($org['name_ar'] ?? '') ?: $org['name'])) : 'EL3AB';
$expires = Time::show($code['expires_at'] ?? null, 'Y/m/d', '');
$welcome = trim((string)($code['welcome_message_ar'] ?? ''));
?><!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ملصق <?= View::e($code['code_display']) ?></title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root { --ink: #14121A; --muted: #5d5869; --gold: #D18D08; }
* { box-sizing: border-box; }
html, body { margin: 0; background: #e9e6f1; color: var(--ink); font-family: 'IBM Plex Sans Arabic', system-ui, sans-serif; }
.sheet { width: 210mm; min-height: 297mm; margin: 12px auto; background: #fff; padding: 18mm 16mm; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8mm; }
.crest { display: flex; align-items: center; gap: 10px; }
.crest img { width: 18mm; height: 18mm; object-fit: contain; }
.org { font-size: 30pt; font-weight: 700; margin: 0; }
.lead { font-size: 26pt; font-weight: 700; margin: 0; }
.welcome { font-size: 14pt; color: var(--muted); margin: 0; max-width: 160mm; }
.qr { width: 120mm; height: 120mm; }
.qr svg { width: 100%; height: 100%; display: block; }
.code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 34pt; font-weight: 700; letter-spacing: 3px; direction: ltr; unicode-bidi: isolate; }
.how { font-size: 13pt; color: var(--muted); margin: 0; }
.link { font-size: 11pt; color: var(--muted); direction: ltr; unicode-bidi: isolate; word-break: break-all; }
.exp { font-size: 13pt; color: var(--gold); font-weight: 600; }
.bar { position: fixed; inset-block-start: 12px; inset-inline-end: 12px; display: flex; gap: 8px; }
.bar button { font: inherit; padding: 8px 16px; border-radius: 8px; border: 0; background: var(--ink); color: #fff; cursor: pointer; }
@page { size: A4 portrait; margin: 0; }
@media print { html, body { background: #fff; } .sheet { margin: 0; box-shadow: none; } .bar { display: none; } }
</style>
</head>
<body>
<div class="bar"><button type="button" onclick="window.print()">طباعة</button></div>
<main class="sheet">
<div class="crest">
<img src="<?= View::e($crownUrl) ?>" alt="" onerror="this.remove()">
<p class="org"><?= View::e($orgName) ?></p>
</div>
<p class="lead">امسح وادخل</p>
<?php if ($welcome !== ''): ?><p class="welcome"><?= View::e($welcome) ?></p><?php endif; ?>
<div class="qr"><?= $qr ?></div>
<div class="code"><?= View::e($code['code_display']) ?></div>
<p class="how">أو افتح تطبيق EL3AB واكتب الكود</p>
<div class="link"><?= View::e($link) ?></div>
<?php if ($expires !== ''): ?><div class="exp">صالح حتى <?= View::e($expires) ?></div><?php endif; ?>
</main>
</body>
</html>
<?php
/**
* One code: its link and QR, the funnel, every redemption with a reverse
* action, and revoke.
* Expects: $org, $base, $code, $stats, $redemptions, $creator, $link, $qr
*/
[$sl, $sb] = RedeemService::STATUS[$code['status']];
$f = $stats['funnel'] ?? [];
$csrf = Auth::csrfToken();
$funnel = [
'opens' => 'فتح الرابط', 'previews' => 'معاينات', 'signups' => 'حسابات جديدة', 'redemptions' => 'استخدامات',
'pending' => 'بانتظار الموافقة', 'rejected' => 'مرفوضة', 'reversed' => 'أُلغيت', 'first_game' => 'لعبوا مباراة', 'first_tournament' => 'دخلوا بطولة',
];
$elig = is_string($code['eligibility'] ?? null) ? json_decode($code['eligibility'], true) : ($code['eligibility'] ?? []);
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/organizations/<?= $org['id'] ?>/invites" class="btn btn-icon btn-ghost">
<a href="<?= $base ?>" class="btn btn-icon btn-ghost" aria-label="رجوع">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<h1>استخدام رابط الدعوة</h1>
<h1><bdi dir="ltr" style="font-family: monospace;"><?= View::e($code['code_display']) ?></bdi></h1>
<span class="badge badge-<?= $sb ?>"><?= $sl ?></span>
</div>
<a href="<?= $base ?>/<?= $code['id'] ?>/poster" target="_blank" rel="noopener" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
ملصق للطباعة
</a>
</div>
<!-- Link Info Summary -->
<div class="card mb-5">
<div class="grid grid-3 gap-4">
<div>
<span class="text-sm text-muted">الكود</span>
<div class="font-medium mt-1">
<code style="font-family: monospace; font-size: 1rem; background: var(--bg-secondary); padding: 2px 8px; border-radius: 4px;"><?= View::e($invite['code']) ?></code>
<div class="grid grid-2 gap-5 mb-5" style="align-items: start;">
<div class="card">
<h3 class="card-title mb-3">الكود</h3>
<div class="flex flex-col gap-3 text-sm">
<div class="flex justify-between gap-3"><span class="text-secondary">الرابط</span>
<span class="flex items-center gap-2"><a href="<?= View::e($link) ?>" target="_blank" rel="noopener" class="text-link text-xs" dir="ltr"><?= View::e($link) ?></a>
<button type="button" class="btn btn-xs btn-ghost" onclick="navigator.clipboard.writeText(<?= View::e(json_encode($link)) ?>); this.textContent='نُسخ';">نسخ</button></span>
</div>
</div>
<div>
<span class="text-sm text-muted">المنظمة</span>
<div class="font-medium mt-1"><?= View::e($org['name_ar'] ?? $org['name']) ?></div>
</div>
<div>
<span class="text-sm text-muted">أنشئ بواسطة</span>
<div class="font-medium mt-1"><?= View::e($invite['created_by'] ?? '-') ?></div>
<div class="flex justify-between"><span class="text-secondary">يمنح</span><span><?= View::e(RedeemService::describeGrants($code['grants'])) ?></span></div>
<div class="flex justify-between"><span class="text-secondary">النوع</span><span><?= ['generated' => 'مولّد', 'numeric' => 'رقمي', 'vanity' => 'مخصص'][$code['kind']] ?? View::e($code['kind']) ?></span></div>
<div class="flex justify-between"><span class="text-secondary">الاستخدام</span><span class="tabular-nums"><?= (int)$code['uses'] ?> / <?= $code['max_uses'] === null ? 'بلا حد' : (int)$code['max_uses'] ?> — محجوز <?= (int)$code['reserved'] ?></span></div>
<div class="flex justify-between"><span class="text-secondary">يبدأ / ينتهي (القاهرة)</span><span class="tabular-nums"><?= Time::show($code['starts_at'] ?? null, 'Y/m/d H:i', 'فورًا') ?> — <?= Time::show($code['expires_at'] ?? null, 'Y/m/d H:i', 'لا ينتهي') ?></span></div>
<?php if (!empty($code['requires_approval'])): ?><div class="flex justify-between"><span class="text-secondary">الموافقة</span><span>تحتاج موافقة إدارة المنظمة</span></div><?php endif; ?>
<?php if (!empty($elig)): ?><div class="flex justify-between"><span class="text-secondary">الأهلية</span><span dir="ltr" class="text-xs"><?= View::e(json_encode($elig, JSON_UNESCAPED_UNICODE)) ?></span></div><?php endif; ?>
<?php if ($code['label'] || $code['campaign']): ?><div class="flex justify-between"><span class="text-secondary">الوصف / الحملة</span><span><?= View::e(($code['label'] ?? '-') . ' / ' . ($code['campaign'] ?? '-')) ?></span></div><?php endif; ?>
<div class="flex justify-between"><span class="text-secondary">أنشأه</span><span><?= View::e($creator ?? '-') ?> — <?= Time::show($code['created_at'] ?? null) ?></span></div>
<?php if (!empty($code['revoked_at'])): ?>
<div class="flex justify-between"><span class="text-secondary">أُلغي</span><span><?= Time::show($code['revoked_at']) ?> — <?= View::e($code['revoke_reason'] ?? '') ?></span></div>
<?php endif; ?>
</div>
</div>
<div class="grid grid-3 gap-4 mt-4">
<div>
<span class="text-sm text-muted">الدور المستهدف</span>
<?php
$roleLabels = ['member' => 'عضو', 'moderator' => 'مشرف', 'admin' => 'مدير'];
$role = $invite['target_role'] ?? 'member';
?>
<div class="font-medium mt-1"><span class="badge badge-default"><?= $roleLabels[$role] ?? $role ?></span></div>
</div>
<div>
<span class="text-sm text-muted">الحالة</span>
<div class="font-medium mt-1">
<?php if ($invite['is_active'] ?? false): ?>
<span class="badge badge-success badge-dot">نشط</span>
<?php else: ?>
<span class="badge badge-danger badge-dot">معطل</span>
<?php endif; ?>
</div>
</div>
<div>
<span class="text-sm text-muted">يتطلب موافقة</span>
<div class="font-medium mt-1"><?= ($invite['requires_approval'] ?? false) ? 'نعم' : 'لا' ?></div>
</div>
<div class="card" style="text-align: center;">
<h3 class="card-title mb-3">QR</h3>
<div style="display: inline-block; background: #fff; padding: 8px; border-radius: var(--radius-md);"><?= $qr ?></div>
<p class="text-xs text-muted mt-2">يفتح الرابط نفسه. للطباعة استخدم الملصق.</p>
</div>
</div>
<!-- Usage Table -->
<div class="data-table-wrapper">
<?php if (empty($uses)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<h3 class="empty-state-title">لا يوجد استخدام</h3>
<p class="empty-state-text">لم يستخدم أحد هذا الرابط بعد</p>
</div>
<div class="card mb-5">
<h3 class="card-title mb-3">القمع</h3>
<div class="grid grid-3 gap-3">
<?php foreach ($funnel as $k => $label): ?>
<div class="stat-card"><div class="stat-content"><div class="stat-label"><?= $label ?></div><div class="stat-value tabular-nums"><?= (int)($f[$k] ?? 0) ?></div></div></div>
<?php endforeach; ?>
</div>
<?php if (!empty($stats['by_source'])): ?>
<p class="text-sm text-secondary mt-3">حسب المصدر:
<?php foreach ($stats['by_source'] as $src => $n): ?><span class="badge badge-default"><?= View::e(RedeemService::SOURCES[$src] ?? $src) ?>: <?= (int)$n ?></span> <?php endforeach; ?>
</p>
<?php endif; ?>
</div>
<div class="card mb-5">
<h3 class="card-title mb-3">الاستخدامات (<?= count($redemptions) ?>)</h3>
<?php if (empty($redemptions)): ?>
<p class="text-sm text-muted p-4">لم يستخدمه أحد بعد</p>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<th>اسم اللاعب</th>
<th>الحالة</th>
<th>تسجيل جديد؟</th>
<th>تاريخ الاستخدام</th>
</tr>
</thead>
<tbody>
<?php foreach ($uses as $use): ?>
<tr>
<td>
<?php
$profile = $use['profiles'] ?? null;
$username = $profile['username'] ?? $profile['display_name'] ?? '-';
?>
<span class="font-medium"><?= View::e($username) ?></span>
</td>
<td>
<?php
$status = $use['status'] ?? 'joined';
$statusLabels = [
'joined' => ['label' => 'انضم', 'class' => 'badge-success'],
'pending' => ['label' => 'بانتظار الموافقة', 'class' => 'badge-warning'],
'rejected' => ['label' => 'مرفوض', 'class' => 'badge-danger'],
];
$statusInfo = $statusLabels[$status] ?? ['label' => $status, 'class' => 'badge-default'];
?>
<span class="badge <?= $statusInfo['class'] ?>"><?= $statusInfo['label'] ?></span>
</td>
<td>
<?php if ($use['is_new_signup'] ?? false): ?>
<span class="badge badge-info">تسجيل جديد</span>
<?php else: ?>
<span class="text-muted">لا</span>
<?php endif; ?>
</td>
<td>
<?= date('Y-m-d H:i', strtotime($use['used_at'])) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<table class="data-table">
<thead><tr><th>اللاعب</th><th>الوقت</th><th>المصدر</th><th>حساب جديد</th><th>الحالة</th><th>إلغاء الاستخدام</th></tr></thead>
<tbody>
<?php foreach ($redemptions as $r): ?>
<?php [$rl, $rb] = RedeemService::REDEMPTION_STATUS[$r['status']] ?? [$r['status'], 'default']; ?>
<tr>
<td><?php if ($r['player']): ?><a href="/players/<?= View::e($r['player_id']) ?>" class="text-link"><?= View::e($r['player']['display_name'] ?: $r['player']['username']) ?></a> <span class="text-xs text-muted" dir="ltr">@<?= View::e($r['player']['username']) ?></span><?php else: ?>-<?php endif; ?></td>
<td class="text-xs tabular-nums"><?= Time::show($r['created_at'] ?? null) ?></td>
<td><?= View::e(RedeemService::SOURCES[$r['source']] ?? $r['source']) ?></td>
<td><?= !empty($r['was_new_signup']) ? '<span class="badge badge-success badge-sm">جديد</span>' : '<span class="text-muted text-xs">لا</span>' ?></td>
<td><span class="badge badge-<?= $rb ?> badge-sm"><?= View::e($rl) ?></span><?php if (!empty($r['reverse_reason'])): ?><div class="text-xs text-muted"><?= View::e($r['reverse_reason']) ?></div><?php endif; ?></td>
<td>
<?php if (in_array($r['status'], ['applied', 'partial', 'pending_approval'], true)): ?>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/redemptions/<?= $r['id'] ?>/reverse" class="flex gap-2 items-center">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="text" name="reason" class="form-input" style="max-width: 160px; padding: var(--space-1) var(--space-2); font-size: 12px;" placeholder="السبب" required>
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('إلغاء هذا الاستخدام؟ تُسحب العضوية والمنح التي أعطاها (والعملات بقدر ما زال مع اللاعب).')">إلغاء</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php if ($code['status'] !== 'revoked'): ?>
<div class="card">
<h3 class="card-title mb-3">إلغاء الكود</h3>
<form method="POST" action="<?= $base ?>/<?= $code['id'] ?>/revoke" class="flex gap-3 items-end flex-wrap">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group" style="margin-bottom: 0; min-width: 280px;">
<label class="form-label">السبب *</label>
<input type="text" name="reason" class="form-input" maxlength="500" required>
</div>
<button type="submit" class="btn btn-danger" onclick="return confirm('إلغاء الكود نهائيًا؟ يتوقف عن العمل فورًا، وتبقى العضويات التي تمت به.')">إلغاء الكود</button>
</form>
</div>
<?php endif; ?>
<?php
/**
* Platform promo codes (redeem_codes with no org: scope platform) and the
* cross-org campaign report with a funnel per campaign (design 2.13, G1).
*
* Code screens are the same as an org's invites (modules/org-invites); a
* platform code is created by staff and may grant platform coins and gems,
* shop items, badges, frames, tournament entry and group joins.
*/
require_once MODULES_PATH . '/org-invites/CodesBase.php';
require_once MODULES_PATH . '/live-tournaments/services/QRCodeService.php';
require_once MODULES_PATH . '/tournaments/services/PlayerEngineService.php';
class RedeemCodesController extends CodesBase
{
protected function context(array $params): array
{
if (Auth::isOrgScoped()) {
http_response_code(403);
View::render('errors/403');
exit;
}
return [null, '/redeem-codes'];
}
protected function scopeQuery(?array $org): array
{
return ['org_id' => 'is.null'];
}
/** Every campaign across every org and the platform, with its funnel. */
public function campaigns(array $params, string $method): void
{
$this->context($params);
$rows = $this->db->rpc('redeem_campaign_stats', ['p_org' => null]);
$rows = is_array($rows) ? $rows : [];
$this->db->clearWriteErrors();
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') {
$rows = array_values(array_filter($rows, fn($r) => stripos($r['campaign'] . ' ' . ($r['org']['name'] ?? '') . ' ' . ($r['org']['name_ar'] ?? ''), $q) !== false));
}
$pageTitle = 'الحملات';
View::render('redeem-codes/campaigns', compact('rows', 'q', 'pageTitle'));
}
}
<?php
/**
* Expects: $rows (redeem_campaign_stats), $q
*/
$steps = [
'opens' => 'فتح الرابط', 'previews' => 'معاينة', 'redemptions' => 'استخدام', 'signups' => 'حساب جديد',
'first_game' => 'لعب مباراة', 'first_tournament' => 'دخل بطولة',
];
?>
<div class="content-header">
<h1>أكواد المنصة</h1>
<div class="flex gap-2">
<a href="/redeem-codes/bulk" class="btn btn-ghost">توليد لحملة</a>
<a href="/redeem-codes/create" class="btn btn-primary">كود جديد</a>
</div>
</div>
<div class="tabs mb-5">
<a href="/redeem-codes" class="tab">الأكواد</a>
<a href="/redeem-codes/campaigns" class="tab active">الحملات والقمع</a>
</div>
<p class="text-sm text-muted mb-4">كل حملة في المنصة وفي كل منظمة. القمع: من فتح الرابط، ثم المعاينة، ثم الاستخدام، ثم من لعب مباراة ومن دخل بطولة بعدها.</p>
<form method="GET" action="/redeem-codes/campaigns" class="flex gap-2 mb-4">
<input type="text" name="q" value="<?= View::e($q) ?>" class="form-input" style="max-width: 280px;" placeholder="بحث باسم الحملة أو المنظمة">
<button type="submit" class="btn btn-ghost">بحث</button>
</form>
<?php if (empty($rows)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد حملات</h3><p class="empty-state-text">الأكواد التي لها اسم حملة تظهر هنا</p></div></div>
<?php else: ?>
<?php foreach ($rows as $r): ?>
<?php
$top = max(1, (int)$r['opens'], (int)$r['previews'], (int)$r['redemptions']);
$orgName = $r['org'] ? ((($r['org']['name_ar'] ?? '') ?: $r['org']['name'])) : 'المنصة';
$codesUrl = $r['org_id'] ? '/organizations/' . $r['org_id'] . '/invites?campaign=' . rawurlencode($r['campaign']) : '/redeem-codes?campaign=' . rawurlencode($r['campaign']);
$exportUrl = $r['org_id'] ? '/organizations/' . $r['org_id'] . '/invites/export?campaign=' . rawurlencode($r['campaign']) : '/redeem-codes/export?campaign=' . rawurlencode($r['campaign']);
?>
<div class="card mb-4">
<div class="card-header flex justify-between items-center flex-wrap gap-2">
<div>
<h3 class="card-title m-0" dir="ltr" style="text-align: start;"><?= View::e($r['campaign']) ?></h3>
<div class="text-xs text-muted"><?= View::e($orgName) ?> — <?= (int)$r['codes'] ?> كود (<?= (int)$r['live_codes'] ?> يعمل)<?= $r['seats'] !== null ? ' — ' . (int)$r['uses'] . ' من ' . (int)$r['seats'] . ' مقعد' : '' ?> — منذ <?= Time::show($r['first_created'] ?? null, 'Y/m/d') ?></div>
</div>
<div class="flex gap-2">
<a href="<?= View::e($codesUrl) ?>" class="btn btn-xs btn-ghost">الأكواد</a>
<a href="<?= View::e($exportUrl) ?>" class="btn btn-xs btn-ghost">تصدير CSV</a>
</div>
</div>
<div class="p-4 flex flex-col gap-2">
<?php foreach ($steps as $k => $label): ?>
<?php $n = (int)($r[$k] ?? 0); ?>
<div class="flex items-center gap-3">
<span class="text-sm" style="width: 110px;"><?= $label ?></span>
<div style="flex: 1; height: 10px; background: var(--bg-secondary); border-radius: 5px; overflow: hidden;">
<div style="height: 100%; width: <?= round($n / $top * 100, 1) ?>%; background: var(--brand-blue); border-radius: 5px;"></div>
</div>
<span class="tabular-nums text-sm" style="width: 60px; text-align: end;"><?= $n ?></span>
</div>
<?php endforeach; ?>
<p class="text-xs text-muted mt-1">بانتظار الموافقة <?= (int)$r['pending'] ?> — مرفوض <?= (int)$r['rejected'] ?> — أُلغي <?= (int)$r['reversed'] ?><?= $r['last_redeemed'] ? ' — آخر استخدام ' . Time::show($r['last_redeemed']) : '' ?></p>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
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