Commit a945b003 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game-content): manager pages for seasons, shop, achievements, challenges, puzzles

The game reads all five tables and the manager had no screen for any of
them; the only battle-pass season ended on 2026-07-01, so the pass has been
dead since. One small config-driven CRUD covers them, every field a real
column, with Arabic labels and a note on what the game actually uses:

- seasons: create the next one (Cairo times), and "activate", which makes
  it the single is_active row the game's battlepass.php reads (an ended
  season cannot be activated, the active one cannot be deleted);
- shop items (cosmetics) with their enum types and rarities;
- achievements, with the condition written as {type, count} and the
  condition types the game never counts marked as such;
- daily challenge templates, marking the types the game does not count;
- puzzles, validating the FEN and the UCI solution.

Deleting something players own or have earned is refused by the foreign
key and says so instead of failing silently.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 52d5591d
......@@ -188,6 +188,16 @@ return [
'settings/update' => ['module' => 'settings', 'action' => 'update'],
'settings/rewards' => ['module' => 'settings', 'action' => 'updateRewards'],
// Game content the app reads (seasons, shop, achievements, daily challenges, puzzles)
'game-content' => ['module' => 'game-content', 'action' => 'index'],
'game-content/{resource}' => ['module' => 'game-content', 'action' => 'list'],
'game-content/{resource}/create' => ['module' => 'game-content', 'action' => 'create'],
'game-content/{resource}/store' => ['module' => 'game-content', 'action' => 'store'],
'game-content/{resource}/{key}/edit' => ['module' => 'game-content', 'action' => 'edit'],
'game-content/{resource}/{key}/update' => ['module' => 'game-content', 'action' => 'update'],
'game-content/{resource}/{key}/delete' => ['module' => 'game-content', 'action' => 'delete'],
'game-content/{resource}/{key}/activate' => ['module' => 'game-content', 'action' => 'activate'],
// Branding
'branding' => ['module' => 'branding', 'action' => 'index'],
'branding/colors' => ['module' => 'branding', 'action' => 'colors'],
......
......@@ -171,6 +171,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"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
<span class="nav-text">الميزات</span>
</a>
<a href="/game-content" class="nav-item <?= navActive('game-content', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><circle cx="15" cy="11" r="1"/><circle cx="18" cy="13" r="1"/></svg>
<span class="nav-text">محتوى اللعبة</span>
</a>
<a href="/settings" class="nav-item <?= navActive('settings', $currentModule) ?>">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 9 3.17V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span class="nav-text">الإعدادات</span>
......
This diff is collapsed.
<?php
/**
* Expects: $key, $res, $row ([] when creating)
*/
$isEdit = !empty($row['id']);
$action = $isEdit ? "/game-content/{$key}/" . rawurlencode($row['id']) . '/update' : "/game-content/{$key}/store";
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/game-content/<?= $key ?>" 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(($isEdit ? 'تعديل ' : 'إضافة ') . $res['singular']) ?></h1>
</div>
</div>
<p class="text-sm text-muted mb-4"><?= View::e($res['note']) ?></p>
<div class="card max-w-lg">
<form method="POST" action="<?= $action ?>">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<?php foreach ($res['fields'] as $name => $f): ?>
<?php
$value = $row[$name] ?? ($f['default'] ?? null);
$dir = isset($f['dir']) ? ' dir="' . $f['dir'] . '"' : '';
$req = !empty($f['required']) ? ' required' : '';
?>
<?php if ($f['type'] === 'bool'): ?>
<label class="flex items-center gap-2 mb-4">
<input type="checkbox" name="<?= $name ?>" value="1" <?= $value ? 'checked' : '' ?>>
<span><?= View::e($f['label']) ?></span>
</label>
<?php continue; ?>
<?php endif; ?>
<div class="form-group">
<label class="form-label"><?= View::e($f['label']) ?><?= !empty($f['required']) || $f['type'] === 'slug' ? ' *' : '' ?></label>
<?php switch ($f['type']):
case 'slug': ?>
<?php if ($isEdit): ?>
<input type="text" class="form-input" value="<?= View::e($value) ?>" dir="ltr" disabled>
<?php else: ?>
<input type="text" name="<?= $name ?>" class="form-input" required dir="ltr" pattern="[a-z0-9_]{3,40}">
<?php endif; ?>
<?php break;
case 'textarea': ?>
<textarea name="<?= $name ?>" class="form-input" rows="3"<?= $dir ?><?= $req ?>><?= View::e($value) ?></textarea>
<?php break;
case 'int': ?>
<input type="number" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>" step="1"<?= isset($f['min']) ? ' min="' . (int)$f['min'] . '"' : '' ?><?= isset($f['max']) ? ' max="' . (int)$f['max'] . '"' : '' ?><?= $req ?>>
<?php break;
case 'select': ?>
<select name="<?= $name ?>" class="form-select"<?= $req ?>>
<?php if (empty($f['required'])): ?><option value="">بدون</option><?php endif; ?>
<?php foreach ($f['options'] as $ov => $ol): ?>
<option value="<?= View::e($ov) ?>" <?= (string)$value === (string)$ov ? 'selected' : '' ?>><?= View::e($ol) ?></option>
<?php endforeach; ?>
</select>
<?php break;
case 'datetime': ?>
<input type="datetime-local" name="<?= $name ?>" class="form-input" value="<?= Time::toInput(is_string($value) ? $value : null) ?>" dir="ltr"<?= $req ?>>
<?php break;
case 'url': ?>
<input type="url" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>" dir="ltr" placeholder="https://...">
<?php break;
case 'condition':
$c = is_string($value) ? json_decode($value, true) : $value;
$c = is_array($c) ? $c : []; ?>
<div class="flex gap-2 items-center flex-wrap">
<select name="condition_type" class="form-select" style="width:auto;" required>
<option value="">اختر</option>
<?php foreach ($f['options'] as $ov => $ol): ?>
<option value="<?= View::e($ov) ?>" <?= ($c['type'] ?? '') === $ov ? 'selected' : '' ?>><?= View::e($ol) ?></option>
<?php endforeach; ?>
</select>
<span class="text-sm text-muted">يبلغ</span>
<input type="number" name="condition_count" class="form-input" style="width: 110px;" min="1" value="<?= (int)($c['count'] ?? 1) ?>" required>
</div>
<?php break;
default: ?>
<input type="text" name="<?= $name ?>" class="form-input" value="<?= View::e($value) ?>"<?= $dir ?><?= $req ?>>
<?php endswitch; ?>
<?php if (!empty($f['hint'])): ?><span class="form-hint"><?= View::e($f['hint']) ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
<div class="flex gap-3">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'حفظ' : 'إضافة' ?></button>
<a href="/game-content/<?= $key ?>" class="btn btn-ghost">إلغاء</a>
</div>
</form>
</div>
<?php
/**
* Expects: $key, $res, $rows
*/
$tabs = [];
foreach (GameContentController::RESOURCES as $k => $r) $tabs[$k] = $r['title'];
$fields = $res['fields'];
$csrf = Auth::csrfToken();
$cell = function (string $name, array $row) use ($fields): string {
$f = $fields[$name] ?? ['type' => 'text'];
$v = $row[$name] ?? null;
switch ($f['type']) {
case 'datetime':
return '<span class="tabular-nums">' . Time::show($v) . '</span>';
case 'bool':
return $v ? '<span class="badge badge-success badge-sm">نعم</span>' : '<span class="badge badge-default badge-sm">لا</span>';
case 'select':
return View::e($f['options'][$v] ?? ($v ?? '-'));
case 'condition':
$c = is_string($v) ? json_decode($v, true) : $v;
if (!is_array($c)) return '-';
return View::e(($f['options'][$c['type'] ?? ''] ?? ($c['type'] ?? '?')) . ': ' . ($c['count'] ?? '?'));
case 'int':
return '<span class="tabular-nums">' . View::e($v ?? '-') . '</span>';
case 'slug':
case 'uci':
return '<span class="text-xs" dir="ltr">' . View::e($v ?? '-') . '</span>';
default:
return View::e($v ?? '-');
}
};
?>
<div class="content-header">
<h1>محتوى اللعبة</h1>
<a href="/game-content/<?= $key ?>/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>
إضافة <?= View::e($res['singular']) ?>
</a>
</div>
<div class="tabs mb-5">
<?php foreach ($tabs as $k => $label): ?>
<a href="/game-content/<?= $k ?>" class="tab <?= $k === $key ? 'active' : '' ?>"><?= View::e($label) ?></a>
<?php endforeach; ?>
<a href="/settings" class="tab">المكافآت</a>
</div>
<p class="text-sm text-muted mb-4"><?= View::e($res['note']) ?></p>
<div class="data-table-wrapper">
<?php if (empty($rows)): ?>
<div class="empty-state">
<h3 class="empty-state-title">لا يوجد شيء بعد</h3>
<p class="empty-state-text">أضف أول <?= View::e($res['singular']) ?></p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr>
<?php foreach ($res['columns'] as $c): ?><th><?= View::e($fields[$c]['label'] ?? $c) ?></th><?php endforeach; ?>
<?php if ($key === 'seasons'): ?><th>الحالة</th><?php endif; ?>
<th style="width: 1%;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<?php foreach ($res['columns'] as $i => $c): ?>
<td class="<?= $i === 0 ? 'font-medium' : '' ?>"><?= $cell($c, $row) ?></td>
<?php endforeach; ?>
<?php if ($key === 'seasons'): ?>
<td>
<?php
$now = time();
$ended = !empty($row['ends_at']) && strtotime($row['ends_at']) < $now;
$upcoming = !empty($row['starts_at']) && strtotime($row['starts_at']) > $now;
?>
<?php if (!empty($row['is_active']) && !$ended): ?>
<span class="badge badge-success">في التطبيق الآن</span>
<?php elseif (!empty($row['is_active']) && $ended): ?>
<span class="badge badge-danger">مفعّل لكنه انتهى</span>
<?php elseif ($ended): ?>
<span class="badge badge-default">انتهى</span>
<?php elseif ($upcoming): ?>
<span class="badge badge-info">قادم</span>
<?php else: ?>
<span class="badge badge-warning">غير مفعّل</span>
<?php endif; ?>
</td>
<?php endif; ?>
<td>
<div class="flex gap-2">
<?php if ($key === 'seasons' && empty($row['is_active']) && !(!empty($row['ends_at']) && strtotime($row['ends_at']) < time())): ?>
<form method="POST" action="/game-content/seasons/<?= rawurlencode($row['id']) ?>/activate" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-xs btn-success" onclick="return confirm('جعل هذا الموسم موسم التطبيق؟ سيتوقف الموسم المفعّل الحالي.')">تفعيل</button>
</form>
<?php endif; ?>
<a href="/game-content/<?= $key ?>/<?= rawurlencode($row['id']) ?>/edit" class="btn btn-xs btn-ghost">تعديل</a>
<form method="POST" action="/game-content/<?= $key ?>/<?= rawurlencode($row['id']) ?>/delete" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-xs btn-ghost text-danger" onclick="return confirm('حذف نهائي؟')">حذف</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
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