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 [ ...@@ -54,6 +54,8 @@ return [
'org-media.*', 'org-media.*',
'org-spotlights.*', 'org-spotlights.*',
'org-announcements.*', 'org-announcements.*',
'redeem-codes.*',
'game-content.*',
], ],
// ─── Moderation focused ────────────────────────────────────────────── // ─── Moderation focused ──────────────────────────────────────────────
......
...@@ -261,9 +261,26 @@ return [ ...@@ -261,9 +261,26 @@ return [
'organizations/{id}/invites' => ['module' => 'org-invites', 'action' => 'list'], 'organizations/{id}/invites' => ['module' => 'org-invites', 'action' => 'list'],
'organizations/{id}/invites/create' => ['module' => 'org-invites', 'action' => 'create'], 'organizations/{id}/invites/create' => ['module' => 'org-invites', 'action' => 'create'],
'organizations/{id}/invites/store' => ['module' => 'org-invites', 'action' => 'store'], 'organizations/{id}/invites/store' => ['module' => 'org-invites', 'action' => 'store'],
'organizations/{id}/invites/{inviteId}/toggle' => ['module' => 'org-invites', 'action' => 'toggle'], 'organizations/{id}/invites/bulk' => ['module' => 'org-invites', 'action' => 'bulk'],
'organizations/{id}/invites/{inviteId}/delete' => ['module' => 'org-invites', 'action' => 'delete'], '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}/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 Membership Applications
'org-applications' => ['module' => 'org-applications', 'action' => 'list'], 'org-applications' => ['module' => 'org-applications', 'action' => 'list'],
......
This diff is collapsed.
...@@ -19,6 +19,7 @@ require_once __DIR__ . '/core/OrgPermissions.php'; ...@@ -19,6 +19,7 @@ require_once __DIR__ . '/core/OrgPermissions.php';
require_once __DIR__ . '/core/OrgScope.php'; require_once __DIR__ . '/core/OrgScope.php';
require_once __DIR__ . '/core/EconomyService.php'; require_once __DIR__ . '/core/EconomyService.php';
require_once __DIR__ . '/core/ProofDocument.php'; require_once __DIR__ . '/core/ProofDocument.php';
require_once __DIR__ . '/core/RedeemService.php';
$route = trim($_GET['route'] ?? '', '/'); $route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
......
...@@ -124,6 +124,10 @@ try { $pendingWorkflows = $isAdmin ? $db->count('approval_requests', ['status' = ...@@ -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> <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> <span class="nav-text">الإطارات</span>
</a> </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) ?>"> <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> <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> <span class="nav-text">طلبات العضوية</span>
......
This diff is collapsed.
<?php <?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; protected function context(array $params): array
public function __construct()
{
$this->db = Database::getInstance();
}
public function list(array $params, string $method): void
{ {
$orgId = $params['id']; $orgId = (string)($params['id'] ?? '');
$org = $this->db->selectOne('el3ab_organizations', ['id' => "eq.{$orgId}"]); $org = preg_match('/^[0-9a-f-]{36}$/i', $orgId) ? $this->db->selectOne('el3ab_organizations', ['id' => 'eq.' . $orgId]) : null;
if (!$org) { if (!$org) {
http_response_code(404); http_response_code(404);
View::render('errors/404'); View::render('errors/404');
return; exit;
}
$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;
} }
return [$org, '/organizations/' . $org['id'] . '/invites'];
$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"
);
} }
public function delete(array $params, string $method): void protected function scopeQuery(?array $org): array
{ {
Auth::requireCsrf(); return ['org_id' => 'eq.' . $org['id']];
$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'));
} }
} }
This diff is collapsed.
This diff is collapsed.
<?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>
This diff is collapsed.
<?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