Commit d6ec628c authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: tournaments hub — full tab with filters, registration, match launch

Replace shop tab with tournaments hub. Players can browse all tournaments,
filter by status, register, and launch pending matches directly from the hub.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3fc863bf
...@@ -18,9 +18,9 @@ if ($method === 'GET') { ...@@ -18,9 +18,9 @@ if ($method === 'GET') {
if ($action === 'list') { if ($action === 'list') {
$tournaments = $db->get('el3ab_tournaments', [ $tournaments = $db->get('el3ab_tournaments', [
'select' => 'id,name,game_key,format,time_control,status,max_players,starts_at,ends_at,prize_pool,entry_fee', 'select' => 'id,name,game_key,format,time_control,status,max_players,swiss_rounds,starts_at,ends_at,prize_pool_coins,entry_fee_coins',
'order' => 'starts_at.desc', 'order' => 'created_at.desc',
'limit' => 20 'limit' => 50
]); ]);
if (!is_array($tournaments) || isset($tournaments['error'])) { if (!is_array($tournaments) || isset($tournaments['error'])) {
...@@ -48,6 +48,17 @@ if ($method === 'GET') { ...@@ -48,6 +48,17 @@ if ($method === 'GET') {
jsonResponse($tournament); jsonResponse($tournament);
} }
if ($action === 'my-registrations') {
$token = requireAuth();
$userId = getUserId($token);
$regs = $db->get('tournament_registrations', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.registered',
'select' => 'tournament_id'
]);
jsonResponse(['registrations' => is_array($regs) && !isset($regs['error']) ? $regs : []]);
}
} }
if ($method === 'POST') { if ($method === 'POST') {
......
...@@ -151,17 +151,17 @@ function formatNum(n) { ...@@ -151,17 +151,17 @@ function formatNum(n) {
} }
function updateTournamentBadge(pending) { function updateTournamentBadge(pending) {
const rankTab = tabBar?.querySelector('[data-world="rank"]'); const tourTab = tabBar?.querySelector('[data-world="tournaments"]');
if (!rankTab) return; if (!tourTab) return;
let badge = rankTab.querySelector('.tour-badge'); let badge = tourTab.querySelector('.tour-badge');
const count = Array.isArray(pending) ? pending.length : 0; const count = Array.isArray(pending) ? pending.length : 0;
if (count > 0) { if (count > 0) {
if (!badge) { if (!badge) {
badge = document.createElement('div'); badge = document.createElement('div');
badge.className = 'tour-badge'; badge.className = 'tour-badge';
badge.style.cssText = 'position:absolute;top:2px;right:8px;width:8px;height:8px;background:#E4AC38;border-radius:50%;'; badge.style.cssText = 'position:absolute;top:2px;right:8px;width:8px;height:8px;background:#E4AC38;border-radius:50%;';
rankTab.style.position = 'relative'; tourTab.style.position = 'relative';
rankTab.appendChild(badge); tourTab.appendChild(badge);
} }
} else if (badge) { } else if (badge) {
badge.remove(); badge.remove();
......
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountLeaderboard } from './scenes/leaderboard.js'; import { mountLeaderboard } from './scenes/leaderboard.js';
import { mountTournaments } from './scenes/tournaments.js';
import { mountTournamentDetail } from './scenes/tournament-detail.js';
import { mountTournamentBracket } from './scenes/tournament-bracket.js';
import { mountTournamentArena } from './scenes/tournament-arena.js';
import { mountTournamentLobby } from './scenes/tournament-lobby.js';
import { mountTournamentLive } from './scenes/tournament-live.js';
scene.register('leaderboard', mountLeaderboard); scene.register('leaderboard', mountLeaderboard);
scene.register('tournaments', mountTournaments);
scene.register('tournament-detail', mountTournamentDetail);
scene.register('tournament-bracket', mountTournamentBracket);
scene.register('tournament-arena', mountTournamentArena);
scene.register('tournament-lobby', mountTournamentLobby);
scene.register('tournament-live', mountTournamentLive);
export { mountTournamentArena } from '../../rank/scenes/tournament-arena.js';
export { mountTournamentBracket } from '../../rank/scenes/tournament-bracket.js';
export { mountTournamentDetail } from '../../rank/scenes/tournament-detail.js';
import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
let activeFilter = 'all';
export async function mountTournamentsHub(el) {
activeFilter = 'all';
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#0a0a1a;">
<div style="padding:14px 16px 0;background:#0f0f1e;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<h2 style="font-size:20px;font-weight:800;color:#f8fafc;">${emoji('tournament_cup', '🏆', 20)} البطولات</h2>
</div>
<div id="tour-filters" style="display:flex;gap:6px;overflow-x:auto;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.04);">
<button class="tour-filter active" data-filter="all">الكل</button>
<button class="tour-filter" data-filter="my">بطولاتي</button>
<button class="tour-filter" data-filter="registration">تسجيل مفتوح</button>
<button class="tour-filter" data-filter="in_progress">جارية</button>
<button class="tour-filter" data-filter="completed">منتهية</button>
</div>
</div>
<div id="tour-hub-content" style="flex:1;overflow-y:auto;padding:14px 16px;"></div>
</div>
<style>
.tour-filter{background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);color:#94a3b8;font-size:12px;font-weight:600;padding:7px 14px;border-radius:8px;cursor:pointer;font-family:inherit;transition:all 0.15s;white-space:nowrap;flex-shrink:0;}
.tour-filter.active{background:#E4AC38;border-color:#E4AC38;color:#000;}
.tour-filter:active{transform:scale(0.95);}
.tour-hub-card{background:#1a1a2e;border-radius:14px;padding:14px;margin-bottom:10px;cursor:pointer;transition:transform 0.1s;border:1px solid rgba(255,255,255,0.04);}
.tour-hub-card:active{transform:scale(0.98);}
.tour-hub-card.registered{border-color:rgba(228,172,56,0.3);}
.tour-hub-card.has-pending{border-color:#E4AC38;box-shadow:0 0 12px rgba(228,172,56,0.15);}
.tour-status-pill{font-size:10px;padding:3px 10px;border-radius:99px;font-weight:700;display:inline-block;}
.tour-stat{display:flex;flex-direction:column;align-items:center;gap:2px;}
.tour-stat-val{font-size:16px;font-weight:800;color:#f8fafc;}
.tour-stat-label{font-size:10px;color:#64748b;}
</style>
`;
el.querySelectorAll('.tour-filter').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('.tour-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.dataset.filter;
loadTournaments(el);
});
});
loadTournaments(el);
}
async function loadTournaments(el) {
const content = el.querySelector('#tour-hub-content');
content.innerHTML = `
<div style="display:flex;flex-direction:column;gap:10px;">
<div style="height:100px;background:#1a1a2e;border-radius:14px;animation:pulse 1.5s infinite;"></div>
<div style="height:100px;background:#1a1a2e;border-radius:14px;animation:pulse 1.5s infinite;animation-delay:0.3s;"></div>
</div>
`;
const userId = store.get('auth.userId');
try {
const [tourData, pendingData] = await Promise.all([
net.get('tournaments.php', { action: 'list' }),
net.get('tournament-match.php', { action: 'my-pending' }).catch(() => ({ pending: [] }))
]);
let tournaments = tourData.tournaments || tourData || [];
const pending = pendingData.pending || [];
const pendingTournamentIds = new Set(pending.map(p => p.tournament_id));
let registrations = [];
if (activeFilter === 'my' || activeFilter === 'all') {
try {
const regData = await net.get('tournaments.php', { action: 'my-registrations' });
registrations = regData.registrations || [];
} catch (e) {}
}
const registeredIds = new Set(registrations.map(r => r.tournament_id));
if (activeFilter === 'my') {
tournaments = tournaments.filter(t => registeredIds.has(t.id));
} else if (activeFilter !== 'all') {
tournaments = tournaments.filter(t => t.status === activeFilter);
}
if (tournaments.length === 0) {
const emptyMessages = {
all: 'لا توجد بطولات حالياً',
my: 'لم تسجّل في أي بطولة بعد',
registration: 'لا توجد بطولات مفتوحة للتسجيل',
in_progress: 'لا توجد بطولات جارية',
completed: 'لا توجد بطولات منتهية'
};
content.innerHTML = `
<div style="text-align:center;padding:48px 20px;">
<div style="font-size:48px;margin-bottom:12px;opacity:0.4;">${emoji('tournament_cup', '🏆', 48)}</div>
<div style="font-size:15px;font-weight:600;color:#f8fafc;margin-bottom:6px;">${emptyMessages[activeFilter] || emptyMessages.all}</div>
<div style="font-size:12px;color:#64748b;">ستظهر البطولات الجديدة هنا</div>
</div>
`;
return;
}
const sortOrder = { in_progress: 0, registration: 1, draft: 2, completed: 3 };
tournaments.sort((a, b) => (sortOrder[a.status] ?? 9) - (sortOrder[b.status] ?? 9));
content.innerHTML = tournaments.map(tour => {
const isRegistered = registeredIds.has(tour.id);
const hasPending = pendingTournamentIds.has(tour.id);
const pendingMatch = pending.find(p => p.tournament_id === tour.id);
return `
<div class="tour-hub-card ${isRegistered ? 'registered' : ''} ${hasPending ? 'has-pending' : ''}" data-id="${tour.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
<div style="flex:1;min-width:0;">
<div style="font-size:15px;font-weight:700;color:#f8fafc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${tour.name || 'بطولة'}</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">${formatGame(tour.game_key)} · ${formatName(tour.format)}</div>
</div>
<span class="tour-status-pill" style="background:${getStatusColor(tour.status)};color:${tour.status === 'in_progress' ? '#000' : '#fff'};">${getStatusLabel(tour.status)}</span>
</div>
<div style="display:flex;gap:12px;margin-bottom:10px;">
<div class="tour-stat">
<span class="tour-stat-val" style="color:#3B82F6;">${tour.player_count || 0}<span style="font-size:11px;color:#64748b;">/${tour.max_players || '∞'}</span></span>
<span class="tour-stat-label">لاعبين</span>
</div>
<div class="tour-stat">
<span class="tour-stat-val" style="color:#E4AC38;">${tour.swiss_rounds || tour.rounds_total || '?'}</span>
<span class="tour-stat-label">جولات</span>
</div>
<div class="tour-stat">
<span class="tour-stat-val" style="color:#10B981;">${formatTimeControl(tour.time_control)}</span>
<span class="tour-stat-label">الوقت</span>
</div>
${tour.prize_pool_coins ? `<div class="tour-stat">
<span class="tour-stat-val" style="color:#F59E0B;">${tour.prize_pool_coins}</span>
<span class="tour-stat-label">${emoji('coin', '🪙', 10)} جائزة</span>
</div>` : ''}
</div>
${tour.starts_at ? `<div style="font-size:11px;color:#475569;margin-bottom:8px;">${emoji('calendar', '📅', 11)} ${formatDate(tour.starts_at)}</div>` : ''}
${hasPending ? `
<div style="background:rgba(228,172,56,0.1);border:1px solid rgba(228,172,56,0.3);border-radius:10px;padding:10px;display:flex;align-items:center;justify-content:space-between;">
<div style="font-size:12px;color:#E4AC38;font-weight:600;">${emoji('swords', '⚔️', 12)} مباراتك جاهزة — ج${pendingMatch?.round_number || ''} vs ${pendingMatch?.opponent_name || 'خصم'}</div>
<button class="play-now-btn" data-tid="${tour.id}" data-rid="${pendingMatch?.round_id}" data-idx="${pendingMatch?.pairing_index}" style="background:#E4AC38;border:none;border-radius:8px;padding:6px 14px;color:#000;font-weight:700;font-size:11px;cursor:pointer;">العب</button>
</div>
` : isRegistered ? `
<div style="font-size:11px;color:#34D399;font-weight:600;">${emoji('checkmark', '✓', 11)} مسجّل</div>
` : tour.status === 'registration' ? `
<button class="register-btn" data-tid="${tour.id}" style="width:100%;background:#E4AC38;border:none;border-radius:10px;padding:12px;color:#000;font-weight:700;font-size:13px;cursor:pointer;margin-top:4px;">${emoji('swords', '⚔️', 13)} سجّل الآن${tour.entry_fee_coins ? ' — ' + tour.entry_fee_coins + ' عملة' : ''}</button>
` : ''}
</div>
`;
}).join('');
content.querySelectorAll('.tour-hub-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.register-btn') || e.target.closest('.play-now-btn')) return;
audio.play('click');
scene.push('tournament-detail', { tournamentId: card.dataset.id });
});
});
content.querySelectorAll('.register-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
audio.play('click');
btn.disabled = true;
btn.textContent = 'جاري التسجيل...';
try {
await net.post('tournaments.php', { action: 'register', tournament_id: btn.dataset.tid });
btn.textContent = '✓ تم التسجيل';
btn.style.background = '#34D399';
btn.closest('.tour-hub-card')?.classList.add('registered');
} catch (err) {
btn.textContent = err.message || 'فشل';
btn.disabled = false;
setTimeout(() => { btn.textContent = 'سجّل الآن'; }, 2000);
}
});
});
content.querySelectorAll('.play-now-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
audio.play('click');
btn.disabled = true;
btn.textContent = '...';
try {
const data = await net.post('tournament-match.php', {
action: 'create-or-join',
tournament_id: btn.dataset.tid,
round_id: btn.dataset.rid,
pairing_index: parseInt(btn.dataset.idx)
});
if (data.error) throw new Error(data.error);
if (data.bye) { audio.play('reward'); btn.textContent = 'BYE ✓'; return; }
scene.push('chess-game', {
mode: 'live',
matchId: data.match_id,
color: data.color,
timeControl: data.time_control || 'rapid_10_0',
opponentId: data.opponent_id,
tournamentId: btn.dataset.tid,
tournamentRound: parseInt(btn.dataset.idx),
recovered: data.already_exists && data.status === 'in_progress'
});
} catch (err) {
btn.textContent = 'فشل';
btn.disabled = false;
}
});
});
} catch (e) {
content.innerHTML = `
<div style="text-align:center;padding:32px;color:#ef4444;">
<div style="margin-bottom:8px;">فشل تحميل البطولات</div>
<button class="btn btn-secondary" id="retry-tour" style="font-size:12px;padding:8px 16px;">حاول مرة أخرى</button>
</div>
`;
content.querySelector('#retry-tour')?.addEventListener('click', () => loadTournaments(el));
}
}
function getStatusColor(status) {
switch (status) {
case 'registration': return '#34D399';
case 'in_progress': return '#E4AC38';
case 'completed': return '#64748b';
default: return '#3B82F6';
}
}
function getStatusLabel(status) {
switch (status) {
case 'registration': return 'تسجيل مفتوح';
case 'in_progress': return 'جارية';
case 'completed': return 'منتهية';
case 'draft': return 'قريباً';
default: return status || 'قادمة';
}
}
function formatName(format) {
const names = { swiss: 'سويسري', round_robin: 'دوري', single_elimination: 'خروج المغلوب', double_elimination: 'خروج مزدوج', arena: 'أرينا', group_stage: 'مجموعات' };
return names[format] || format || '';
}
function formatGame(key) {
const games = { chess: 'شطرنج', ludo: 'لودو', domino: 'دومينو' };
return games[key] || key || 'شطرنج';
}
function formatTimeControl(tc) {
if (!tc) return '?';
const parts = tc.split('_');
if (parts.length >= 2) return parts[1] + (parts[2] && parts[2] !== '0' ? '+' + parts[2] : '') + 'د';
return tc;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diff = d - now;
if (diff > 0 && diff < 86400000) {
const hours = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
if (hours > 0) return `تبدأ بعد ${hours} ساعة`;
return `تبدأ بعد ${mins} دقيقة`;
}
if (diff > 0 && diff < 7 * 86400000) {
const days = Math.ceil(diff / 86400000);
return `بعد ${days} يوم`;
}
return d.toLocaleDateString('ar', { day: 'numeric', month: 'short', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
}
export { mountTournamentLive } from '../../rank/scenes/tournament-live.js';
export { mountTournamentLobby } from '../../rank/scenes/tournament-lobby.js';
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