Commit b9932348 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: complete platform gap fixes — ELO, XP, shop, achievements, notifications, feature flags

- api/game.php: ELO rating update after bot games (K-factor from DB), XP awards
  (50/game + 30 win bonus), level-up detection, economy transaction logging,
  game stats (total_games_played, wins/draws/losses)
- api/daily-reward.php: XP award (25/claim), level-up check, economy tx logging
- api/shop.php: rewritten to use correct tables (cosmetics + player_cosmetics),
  equip/unequip support, feature flag check, economy tx logging
- api/friends.php: notifications on friend request sent/accepted
- api/matchmaking.php: notifications on match found, feature flag check
- api/bots.php: feature flag check (bot_games_enabled)
- config/database.php: added check_feature_flag() helper
- includes/feature-flags.php: new cached feature flag system (60s file cache)
- includes/header.php: includes feature-flags.php
- pages/shop.php: updated to use cosmetic_type enums, equip UI
- templates/nav-*.php: hide disabled features (shop, tournaments)
- DB: seeded 25 achievements, 11 cosmetics, 1 test tournament, disabled trivia
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ae663aab
<?php <?php
require_once __DIR__ . '/../config/constants.php'; require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
if (!check_feature_flag('bot_games_enabled')) {
http_response_code(403);
echo json_encode(['error' => 'feature_disabled', 'message' => 'اللعب ضد البوت معطل حالياً']);
exit;
}
$cacheFile = sys_get_temp_dir() . '/el3ab_bots_cache.json'; $cacheFile = sys_get_temp_dir() . '/el3ab_bots_cache.json';
$cacheTTL = 300; // 5 minutes $cacheTTL = 300; // 5 minutes
......
...@@ -14,7 +14,7 @@ if (!$token) { ...@@ -14,7 +14,7 @@ if (!$token) {
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST') { if ($method === 'POST') {
$profileRes = supabase_rest('GET', 'profiles?select=id,coins,daily_streak,last_daily_claim', [], $token); $profileRes = supabase_rest('GET', 'profiles?select=id,coins,xp,level,daily_streak,last_daily_claim', [], $token);
if (empty($profileRes['data'])) { if (empty($profileRes['data'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'profile not found']); echo json_encode(['error' => 'profile not found']);
...@@ -46,17 +46,62 @@ if ($method === 'POST') { ...@@ -46,17 +46,62 @@ if ($method === 'POST') {
$reward = $rewardBase + ($streak - 1) * $rewardBonus; $reward = $rewardBase + ($streak - 1) * $rewardBonus;
$newCoins = ($profile['coins'] ?? 0) + $reward; $newCoins = ($profile['coins'] ?? 0) + $reward;
supabase_rest('PATCH', "profiles?id=eq.{$profile['id']}", [ // XP award for daily claim
$dailyXp = 25;
$newXp = (int)($profile['xp'] ?? 0) + $dailyXp;
$currentLevel = (int)($profile['level'] ?? 1);
$profileUpdate = [
'coins' => $newCoins, 'coins' => $newCoins,
'xp' => $newXp,
'daily_streak' => $streak, 'daily_streak' => $streak,
'last_daily_claim' => $today 'last_daily_claim' => $today,
], $token); ];
// Check for level up
$levelRes = supabase_rest('GET', "xp_levels?level=eq." . ($currentLevel + 1) . "&select=level,xp_required,reward_coins", [], SUPABASE_SERVICE_KEY);
$levelUpCoinsBonus = 0;
if (!empty($levelRes['data']) && $newXp >= (int)$levelRes['data'][0]['xp_required']) {
$newLevel = (int)$levelRes['data'][0]['level'];
$levelUpCoinsBonus = (int)($levelRes['data'][0]['reward_coins'] ?? 0);
$profileUpdate['level'] = $newLevel;
$newCoins += $levelUpCoinsBonus;
$profileUpdate['coins'] = $newCoins;
}
supabase_rest('PATCH', "profiles?id=eq.{$profile['id']}", $profileUpdate, $token);
// Log economy_transaction for daily reward coins
supabase_rest('POST', 'economy_transactions', [
'player_id' => $profile['id'],
'type' => 'daily_reward',
'currency' => 'coins',
'amount' => $reward,
'balance_after' => $newCoins - $levelUpCoinsBonus,
'reason' => "Daily reward day {$streak}",
'source_id' => null,
], SUPABASE_SERVICE_KEY);
// Log level up reward separately if applicable
if ($levelUpCoinsBonus > 0) {
supabase_rest('POST', 'economy_transactions', [
'player_id' => $profile['id'],
'type' => 'level_up_reward',
'currency' => 'coins',
'amount' => $levelUpCoinsBonus,
'balance_after' => $newCoins,
'reason' => 'Level up reward',
'source_id' => null,
], SUPABASE_SERVICE_KEY);
}
echo json_encode([ echo json_encode([
'ok' => true, 'ok' => true,
'reward' => $reward, 'reward' => $reward,
'streak' => $streak, 'streak' => $streak,
'coins' => $newCoins 'coins' => $newCoins,
'xp_awarded' => $dailyXp,
'new_xp' => $newXp,
]); ]);
} else { } else {
http_response_code(405); http_response_code(405);
......
...@@ -57,6 +57,10 @@ if ($method === 'GET') { ...@@ -57,6 +57,10 @@ if ($method === 'GET') {
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? ''; $action = $input['action'] ?? '';
// Get current user info for notifications
$currentUser = supabase_auth('user', [], $token, 'GET');
$currentUserId = $currentUser['data']['id'] ?? null;
switch ($action) { switch ($action) {
case 'add': case 'add':
$userId = $input['user_id'] ?? ''; $userId = $input['user_id'] ?? '';
...@@ -64,15 +68,52 @@ if ($method === 'GET') { ...@@ -64,15 +68,52 @@ if ($method === 'GET') {
'friend_id' => $userId, 'friend_id' => $userId,
'status' => 'pending' 'status' => 'pending'
], $token); ], $token);
// Send notification to the recipient
if ($currentUserId) {
$profileRes = supabase_rest('GET', "profiles?id=eq.{$currentUserId}&select=display_name,username", [], SUPABASE_SERVICE_KEY);
$senderName = $profileRes['data'][0]['display_name'] ?? $profileRes['data'][0]['username'] ?? 'لاعب';
supabase_rest('POST', 'notifications', [
'user_id' => $userId,
'type' => 'friend_request',
'title' => 'طلب صداقة',
'title_ar' => 'طلب صداقة',
'body' => "{$senderName} أرسل لك طلب صداقة",
'body_ar' => "{$senderName} أرسل لك طلب صداقة",
'data' => json_encode(['requester_id' => $currentUserId]),
], SUPABASE_SERVICE_KEY);
}
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
break; break;
case 'accept': case 'accept':
$requestId = $input['request_id'] ?? ''; $requestId = $input['request_id'] ?? '';
// Get the friend request to find the original sender
$friendReq = supabase_rest('GET', "friends?id=eq.{$requestId}&select=user_id", [], SUPABASE_SERVICE_KEY);
$requesterId = $friendReq['data'][0]['user_id'] ?? null;
supabase_rest('PATCH', "friends?id=eq.{$requestId}", [ supabase_rest('PATCH', "friends?id=eq.{$requestId}", [
'status' => 'accepted', 'status' => 'accepted',
'accepted_at' => date('c') 'accepted_at' => date('c')
], $token); ], $token);
// Notify the original requester that the request was accepted
if ($requesterId && $currentUserId) {
$profileRes = supabase_rest('GET', "profiles?id=eq.{$currentUserId}&select=display_name,username", [], SUPABASE_SERVICE_KEY);
$acceptorName = $profileRes['data'][0]['display_name'] ?? $profileRes['data'][0]['username'] ?? 'لاعب';
supabase_rest('POST', 'notifications', [
'user_id' => $requesterId,
'type' => 'friend_accepted',
'title' => 'تم قبول طلب الصداقة',
'title_ar' => 'تم قبول طلب الصداقة',
'body' => "{$acceptorName} قبل طلب صداقتك",
'body_ar' => "{$acceptorName} قبل طلب صداقتك",
'data' => json_encode(['friend_id' => $currentUserId]),
], SUPABASE_SERVICE_KEY);
}
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
break; break;
......
...@@ -155,6 +155,10 @@ switch ($action) { ...@@ -155,6 +155,10 @@ switch ($action) {
$pgn = $input['pgn'] ?? ''; $pgn = $input['pgn'] ?? '';
$finalFen = $input['final_fen'] ?? ''; $finalFen = $input['final_fen'] ?? '';
$moves = $input['moves'] ?? null; $moves = $input['moves'] ?? null;
$botId = $input['bot_id'] ?? '';
$timeControl = $input['time_control'] ?? 600;
$increment = $input['increment'] ?? 0;
$rated = $input['rated'] ?? true;
$resultMap = ['win' => 'white_wins', 'loss' => 'black_wins', 'draw' => 'draw']; $resultMap = ['win' => 'white_wins', 'loss' => 'black_wins', 'draw' => 'draw'];
...@@ -173,7 +177,174 @@ switch ($action) { ...@@ -173,7 +177,174 @@ switch ($action) {
supabase_rest('PATCH', "matches?id=eq.{$gameId}", $update, $token); supabase_rest('PATCH', "matches?id=eq.{$gameId}", $update, $token);
} }
echo json_encode(['ok' => true, 'result' => $result, 'game_id' => $gameId]); // --- Get player profile for ELO/XP/coins updates ---
$userRes = supabase_auth('user', [], $token, 'GET');
$userId = $userRes['data']['id'] ?? null;
if ($userId) {
$profileRes = supabase_rest('GET', "profiles?id=eq.{$userId}&select=id,elo_bullet,elo_blitz,elo_rapid,elo_classical,xp,level,coins,games_played,total_games_played,total_wins,total_draws,total_losses", [], SUPABASE_SERVICE_KEY);
$profile = $profileRes['data'][0] ?? null;
if ($profile) {
// --- Load system_config values ---
$cfgRes = supabase_rest('GET', 'system_config?select=key,value&key=in.(elo_k_factor_new,elo_k_factor_established,xp_per_game,xp_per_win_bonus,coins_per_win_casual,coins_per_win_ranked)', [], SUPABASE_SERVICE_KEY);
$cfg = [];
if (!empty($cfgRes['data'])) {
foreach ($cfgRes['data'] as $c) {
$cfg[$c['key']] = $c['value'];
}
}
$kNew = (int)($cfg['elo_k_factor_new'] ?? 40);
$kEstablished = (int)($cfg['elo_k_factor_established'] ?? 20);
$xpPerGame = (int)($cfg['xp_per_game'] ?? 50);
$xpPerWinBonus = (int)($cfg['xp_per_win_bonus'] ?? 30);
$coinsPerWinCasual = (int)($cfg['coins_per_win_casual'] ?? 10);
$coinsPerWinRanked = (int)($cfg['coins_per_win_ranked'] ?? 25);
// Determine time control type for ELO field
$tcSeconds = (int)$timeControl;
if ($tcSeconds < 180) {
$eloField = 'elo_bullet';
$tcType = 'bullet';
} elseif ($tcSeconds < 600) {
$eloField = 'elo_blitz';
$tcType = 'blitz';
} else {
$eloField = 'elo_rapid';
$tcType = 'rapid';
}
$playerElo = (int)($profile[$eloField] ?? 1200);
$gamesPlayed = (int)($profile['games_played'] ?? $profile['total_games_played'] ?? 0);
$kFactor = ($gamesPlayed < 30) ? $kNew : $kEstablished;
// Bot ELO mapping (average of elo_min/elo_max from Stockfish API bots)
$botEloMap = [
'nour' => 400,
'salma' => 600,
'amr' => 800,
'yasmine' => 1000,
'omar' => 1200,
'layla' => 1400,
'karim' => 1600,
'farida' => 1800,
'tarek' => 2000,
'maha' => 2200,
'ziad' => 2500,
'grandmaster' => 2800,
];
$botElo = $botEloMap[$botId] ?? 1200;
// --- ELO Calculation (standard formula) ---
$profileUpdate = [];
$ratingChange = 0;
$newElo = $playerElo;
if ($rated) {
$expected = 1.0 / (1.0 + pow(10, ($botElo - $playerElo) / 400.0));
$actual = ($result === 'win') ? 1.0 : (($result === 'draw') ? 0.5 : 0.0);
$ratingChange = (int)round($kFactor * ($actual - $expected));
$newElo = max(100, $playerElo + $ratingChange);
$profileUpdate[$eloField] = $newElo;
// INSERT into rating_history
supabase_rest('POST', 'rating_history', [
'player_id' => $userId,
'game_key' => 'chess',
'time_control_type' => $tcType,
'rating_before' => $playerElo,
'rating_after' => $newElo,
'rating_change' => $ratingChange,
'match_id' => (!str_starts_with($gameId, 'game_')) ? $gameId : null,
'opponent_id' => null,
'opponent_rating' => $botElo,
'result' => $result,
'k_factor' => $kFactor,
], SUPABASE_SERVICE_KEY);
}
// --- XP Awards ---
$xpAwarded = $xpPerGame;
if ($result === 'win') {
$xpAwarded += $xpPerWinBonus;
}
$newXp = (int)($profile['xp'] ?? 0) + $xpAwarded;
$currentLevel = (int)($profile['level'] ?? 1);
$profileUpdate['xp'] = $newXp;
// Check for level up
$levelRes = supabase_rest('GET', "xp_levels?level=eq." . ($currentLevel + 1) . "&select=level,xp_required,reward_coins", [], SUPABASE_SERVICE_KEY);
$levelUpCoinsBonus = 0;
if (!empty($levelRes['data']) && $newXp >= (int)$levelRes['data'][0]['xp_required']) {
$newLevel = (int)$levelRes['data'][0]['level'];
$levelUpCoinsBonus = (int)($levelRes['data'][0]['reward_coins'] ?? 0);
$profileUpdate['level'] = $newLevel;
}
// --- Coins Awards ---
$coinsAwarded = 0;
if ($result === 'win') {
$coinsAwarded = $rated ? $coinsPerWinRanked : $coinsPerWinCasual;
}
$totalCoinsAwarded = $coinsAwarded + $levelUpCoinsBonus;
$newCoins = (int)($profile['coins'] ?? 0) + $totalCoinsAwarded;
if ($totalCoinsAwarded > 0) {
$profileUpdate['coins'] = $newCoins;
}
// Update games played count
$profileUpdate['total_games_played'] = $gamesPlayed + 1;
if (isset($profile['games_played'])) {
$profileUpdate['games_played'] = $gamesPlayed + 1;
}
if ($result === 'win') {
$profileUpdate['total_wins'] = (int)($profile['total_wins'] ?? 0) + 1;
} elseif ($result === 'draw') {
$profileUpdate['total_draws'] = (int)($profile['total_draws'] ?? 0) + 1;
} else {
$profileUpdate['total_losses'] = (int)($profile['total_losses'] ?? 0) + 1;
}
// Apply profile updates
if (!empty($profileUpdate)) {
supabase_rest('PATCH', "profiles?id=eq.{$userId}", $profileUpdate, SUPABASE_SERVICE_KEY);
}
// --- Log economy_transactions for coins earned ---
if ($coinsAwarded > 0) {
supabase_rest('POST', 'economy_transactions', [
'player_id' => $userId,
'type' => 'game_reward',
'currency' => 'coins',
'amount' => $coinsAwarded,
'balance_after' => $newCoins - $levelUpCoinsBonus,
'reason' => 'Win reward vs bot ' . $botId,
'source_id' => $gameId,
], SUPABASE_SERVICE_KEY);
}
if ($levelUpCoinsBonus > 0) {
supabase_rest('POST', 'economy_transactions', [
'player_id' => $userId,
'type' => 'level_up_reward',
'currency' => 'coins',
'amount' => $levelUpCoinsBonus,
'balance_after' => $newCoins,
'reason' => 'Level up reward',
'source_id' => $gameId,
], SUPABASE_SERVICE_KEY);
}
}
}
echo json_encode([
'ok' => true,
'result' => $result,
'game_id' => $gameId,
'elo_change' => $ratingChange ?? 0,
'new_elo' => $newElo ?? null,
'xp_awarded' => $xpAwarded ?? 0,
'coins_awarded' => $totalCoinsAwarded ?? 0,
]);
break; break;
default: default:
......
...@@ -3,6 +3,12 @@ require_once __DIR__ . '/../config/database.php'; ...@@ -3,6 +3,12 @@ require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
if (!check_feature_flag('matchmaking_enabled')) {
http_response_code(403);
echo json_encode(['error' => 'feature_disabled', 'message' => 'البحث عن مباريات معطل حالياً']);
exit;
}
$token = get_auth_token(); $token = get_auth_token();
if (!$token) { if (!$token) {
...@@ -178,6 +184,31 @@ switch ($action) { ...@@ -178,6 +184,31 @@ switch ($action) {
supabase_service('POST', 'matchmaking_queue', $queueEntry); supabase_service('POST', 'matchmaking_queue', $queueEntry);
// Notify both players about the match
$playerProfileRes = supabase_service('GET', "players?id=eq.{$userId}&select=display_name,username");
$opponentProfileRes = supabase_service('GET', "players?id=eq.{$opponent['player_id']}&select=display_name,username");
$playerName = $playerProfileRes['data'][0]['display_name'] ?? $playerProfileRes['data'][0]['username'] ?? 'لاعب';
$opponentName = $opponentProfileRes['data'][0]['display_name'] ?? $opponentProfileRes['data'][0]['username'] ?? 'لاعب';
supabase_service('POST', 'notifications', [
'user_id' => $opponent['player_id'],
'type' => 'match_found',
'title' => 'تم إيجاد مباراة',
'title_ar' => 'تم إيجاد مباراة',
'body' => "مباراة جديدة ضد {$playerName}",
'body_ar' => "مباراة جديدة ضد {$playerName}",
'data' => json_encode(['match_id' => $matchId, 'opponent_id' => $userId]),
]);
supabase_service('POST', 'notifications', [
'user_id' => $userId,
'type' => 'match_found',
'title' => 'تم إيجاد مباراة',
'title_ar' => 'تم إيجاد مباراة',
'body' => "مباراة جديدة ضد {$opponentName}",
'body_ar' => "مباراة جديدة ضد {$opponentName}",
'data' => json_encode(['match_id' => $matchId, 'opponent_id' => $opponent['player_id']]),
]);
echo json_encode([ echo json_encode([
'status' => 'matched', 'status' => 'matched',
'match_id' => $matchId, 'match_id' => $matchId,
......
...@@ -3,6 +3,12 @@ require_once __DIR__ . '/../config/database.php'; ...@@ -3,6 +3,12 @@ require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
if (!check_feature_flag('cosmetics_shop_enabled')) {
http_response_code(403);
echo json_encode(['error' => 'feature_disabled', 'message' => 'المتجر معطل حالياً']);
exit;
}
$token = get_auth_token(); $token = get_auth_token();
if (!$token) { if (!$token) {
...@@ -14,19 +20,24 @@ if (!$token) { ...@@ -14,19 +20,24 @@ if (!$token) {
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') { if ($method === 'GET') {
$category = $_GET['category'] ?? 'boards'; $type = $_GET['type'] ?? 'board_theme';
$res = supabase_rest('GET', "shop_items?category=eq.{$category}&select=*&order=price.asc", [], $token); // Get cosmetics filtered by type, only purchasable items
$res = supabase_rest('GET', "cosmetics?type=eq.{$type}&is_purchasable=eq.true&select=*&order=price_coins.asc", [], $token);
$items = ($res['status'] === 200 && is_array($res['data']) && !isset($res['data']['code'])) ? $res['data'] : []; $items = ($res['status'] === 200 && is_array($res['data']) && !isset($res['data']['code'])) ? $res['data'] : [];
if (!empty($items)) { if (!empty($items)) {
$ownedRes = supabase_rest('GET', 'user_items?select=item_id', [], $token); // Get owned cosmetics for the current user
$ownedIds = []; $ownedRes = supabase_rest('GET', 'player_cosmetics?select=cosmetic_id,is_equipped', [], $token);
$ownedMap = [];
if ($ownedRes['status'] === 200 && is_array($ownedRes['data'])) { if ($ownedRes['status'] === 200 && is_array($ownedRes['data'])) {
$ownedIds = array_column($ownedRes['data'], 'item_id'); foreach ($ownedRes['data'] as $pc) {
$ownedMap[$pc['cosmetic_id']] = $pc['is_equipped'] ?? false;
}
} }
foreach ($items as &$item) { foreach ($items as &$item) {
$item['owned'] = in_array($item['id'], $ownedIds); $item['owned'] = isset($ownedMap[$item['id']]);
$item['equipped'] = $ownedMap[$item['id']] ?? false;
} }
} }
...@@ -37,9 +48,10 @@ if ($method === 'GET') { ...@@ -37,9 +48,10 @@ if ($method === 'GET') {
$action = $input['action'] ?? ''; $action = $input['action'] ?? '';
if ($action === 'buy') { if ($action === 'buy') {
$itemId = $input['item_id'] ?? ''; $cosmeticId = $input['item_id'] ?? '';
$itemRes = supabase_rest('GET', "shop_items?id=eq.{$itemId}&select=*", [], $token); // Fetch cosmetic details
$itemRes = supabase_rest('GET', "cosmetics?id=eq.{$cosmeticId}&select=*", [], $token);
if (empty($itemRes['data'])) { if (empty($itemRes['data'])) {
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'item not found']); echo json_encode(['error' => 'item not found']);
...@@ -47,7 +59,22 @@ if ($method === 'GET') { ...@@ -47,7 +59,22 @@ if ($method === 'GET') {
} }
$item = $itemRes['data'][0]; $item = $itemRes['data'][0];
$profileRes = supabase_rest('GET', 'profiles?select=coins,gems', [], $token); if (!($item['is_purchasable'] ?? false)) {
http_response_code(400);
echo json_encode(['error' => 'هذا العنصر غير متاح للشراء']);
exit;
}
// Check if already owned
$ownedCheck = supabase_rest('GET', "player_cosmetics?cosmetic_id=eq.{$cosmeticId}&select=id", [], $token);
if (!empty($ownedCheck['data'])) {
http_response_code(400);
echo json_encode(['error' => 'أنت تملك هذا العنصر بالفعل']);
exit;
}
// Get player profile
$profileRes = supabase_rest('GET', 'profiles?select=id,coins,gems', [], $token);
if (empty($profileRes['data'])) { if (empty($profileRes['data'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'profile not found']); echo json_encode(['error' => 'profile not found']);
...@@ -55,34 +82,119 @@ if ($method === 'GET') { ...@@ -55,34 +82,119 @@ if ($method === 'GET') {
} }
$profile = $profileRes['data'][0]; $profile = $profileRes['data'][0];
$currency = $item['currency'] ?? 'coins'; // Determine currency: prefer gems if price_gems is set and coins price is 0 or null
$price = $item['price'] ?? 0; $priceCoins = $item['price_coins'] ?? 0;
$balance = $profile[$currency] ?? 0; $priceGems = $item['price_gems'] ?? 0;
if ($balance < $price) { // Use coins by default, use gems if specified in request
$useCurrency = $input['currency'] ?? 'coins';
if ($useCurrency === 'gems' && $priceGems > 0) {
if (($profile['gems'] ?? 0) < $priceGems) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'رصيد غير كافي']); echo json_encode(['error' => 'رصيد الجواهر غير كافي']);
exit; exit;
} }
$newBalance = ($profile['gems'] ?? 0) - $priceGems;
supabase_rest('POST', 'user_items', [ $deductCurrency = 'gems';
'item_id' => $itemId, $deductPrice = $priceGems;
// Deduct gems
supabase_rest('PATCH', 'profiles?id=eq.' . $profile['id'], [
'gems' => $newBalance
], $token); ], $token);
} else {
if (($profile['coins'] ?? 0) < $priceCoins) {
http_response_code(400);
echo json_encode(['error' => 'رصيد العملات غير كافي']);
exit;
}
$newBalance = ($profile['coins'] ?? 0) - $priceCoins;
$deductCurrency = 'coins';
$deductPrice = $priceCoins;
// Deduct coins
supabase_rest('PATCH', 'profiles?id=eq.' . $profile['id'], [
'coins' => $newBalance
], $token);
}
supabase_rest('PATCH', 'profiles?id=eq.' . ($profile['id'] ?? ''), [ // Insert into player_cosmetics
$currency => $balance - $price $insertRes = supabase_rest('POST', 'player_cosmetics', [
'player_id' => $profile['id'],
'cosmetic_id' => $cosmeticId,
'acquired_via' => 'purchase',
'is_equipped' => false,
], $token); ], $token);
if ($insertRes['status'] >= 400) {
http_response_code(400);
echo json_encode(['error' => 'فشل في إتمام الشراء']);
exit;
}
// Log economy_transaction for shop purchase
supabase_rest('POST', 'economy_transactions', [
'player_id' => $profile['id'],
'type' => 'shop_purchase',
'currency' => $deductCurrency,
'amount' => -$deductPrice,
'balance_after' => $newBalance,
'reason' => 'Purchased cosmetic: ' . ($item['name'] ?? $cosmeticId),
'source_id' => $cosmeticId,
], SUPABASE_SERVICE_KEY);
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
} elseif ($action === 'equip') { } elseif ($action === 'equip') {
$itemId = $input['item_id'] ?? ''; $cosmeticId = $input['item_id'] ?? '';
$slot = $input['slot'] ?? ''; $equip = $input['equip'] ?? true;
// Get the cosmetic type to unequip others of same type
$itemRes = supabase_rest('GET', "cosmetics?id=eq.{$cosmeticId}&select=type", [], $token);
if (empty($itemRes['data'])) {
http_response_code(404);
echo json_encode(['error' => 'item not found']);
exit;
}
$cosmeticType = $itemRes['data'][0]['type'];
supabase_rest('PATCH', 'profiles', [ // Get player_id
'equipped_' . $slot => $itemId $profileRes = supabase_rest('GET', 'profiles?select=id', [], $token);
if (empty($profileRes['data'])) {
http_response_code(400);
echo json_encode(['error' => 'profile not found']);
exit;
}
$playerId = $profileRes['data'][0]['id'];
if ($equip) {
// Unequip all cosmetics of same type for this player
// Get all equipped cosmetics of same type
$equippedRes = supabase_rest('GET', "player_cosmetics?player_id=eq.{$playerId}&is_equipped=eq.true&select=id,cosmetic_id", [], $token);
if (!empty($equippedRes['data'])) {
foreach ($equippedRes['data'] as $equipped) {
// Check if this cosmetic is of the same type
$typeCheck = supabase_rest('GET', "cosmetics?id=eq.{$equipped['cosmetic_id']}&type=eq.{$cosmeticType}&select=id", [], $token);
if (!empty($typeCheck['data'])) {
supabase_rest('PATCH', "player_cosmetics?id=eq.{$equipped['id']}", [
'is_equipped' => false
], $token); ], $token);
}
}
}
// Equip the selected cosmetic
supabase_rest('PATCH', "player_cosmetics?player_id=eq.{$playerId}&cosmetic_id=eq.{$cosmeticId}", [
'is_equipped' => true
], $token);
} else {
// Unequip
supabase_rest('PATCH', "player_cosmetics?player_id=eq.{$playerId}&cosmetic_id=eq.{$cosmeticId}", [
'is_equipped' => false
], $token);
}
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
} else { } else {
echo json_encode(['error' => 'invalid action']); echo json_encode(['error' => 'invalid action']);
} }
......
...@@ -56,6 +56,11 @@ function supabase_rest(string $method, string $endpoint, array $data = [], ?stri ...@@ -56,6 +56,11 @@ function supabase_rest(string $method, string $endpoint, array $data = [], ?stri
]; ];
} }
function check_feature_flag(string $flag): bool {
$res = supabase_rest('GET', "feature_flags?id=eq.{$flag}&select=is_enabled", [], SUPABASE_SERVICE_KEY);
return !empty($res['data']) && $res['data'][0]['is_enabled'] === true;
}
function supabase_auth(string $endpoint, array $data = [], ?string $token = null, string $method = 'POST'): array { function supabase_auth(string $endpoint, array $data = [], ?string $token = null, string $method = 'POST'): array {
$url = SUPABASE_URL . '/auth/v1/' . ltrim($endpoint, '/'); $url = SUPABASE_URL . '/auth/v1/' . ltrim($endpoint, '/');
$headers = [ $headers = [
......
<?php
/**
* Feature flags with file-based caching (60s TTL).
* Include this file to use is_feature_enabled($flag).
*/
require_once __DIR__ . '/../config/database.php';
function _load_feature_flags(): array {
$cacheFile = '/tmp/el3ab_feature_flags.json';
$cacheTTL = 60; // seconds
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTTL) {
$cached = json_decode(file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
// Fetch all flags from DB
$res = supabase_rest('GET', 'feature_flags?select=id,is_enabled', [], SUPABASE_SERVICE_KEY);
$flags = [];
if (!empty($res['data']) && is_array($res['data'])) {
foreach ($res['data'] as $row) {
$flags[$row['id']] = (bool)$row['is_enabled'];
}
}
// Write cache
file_put_contents($cacheFile, json_encode($flags));
return $flags;
}
function is_feature_enabled(string $flag): bool {
static $flags = null;
if ($flags === null) {
$flags = _load_feature_flags();
}
return $flags[$flag] ?? false;
}
<?php require_once __DIR__ . '/feature-flags.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ar" dir="rtl"> <html lang="ar" dir="rtl">
<head> <head>
......
...@@ -30,10 +30,10 @@ ...@@ -30,10 +30,10 @@
<!-- Category Tabs --> <!-- Category Tabs -->
<div class="tab-group" id="shop-tabs"> <div class="tab-group" id="shop-tabs">
<button class="tab active" data-cat="boards">رقع</button> <button class="tab active" data-cat="board_theme">رقعة اللعب</button>
<button class="tab" data-cat="pieces">قطع</button> <button class="tab" data-cat="piece_set">طقم القطع</button>
<button class="tab" data-cat="avatars">صور</button> <button class="tab" data-cat="avatar_frame">إطار الصورة</button>
<button class="tab" data-cat="effects">تأثيرات</button> <button class="tab" data-cat="trail_effect">تأثيرات الحركة</button>
</div> </div>
<!-- Items Grid --> <!-- Items Grid -->
...@@ -50,14 +50,14 @@ document.addEventListener('DOMContentLoaded', async () => { ...@@ -50,14 +50,14 @@ document.addEventListener('DOMContentLoaded', async () => {
return; return;
} }
let currentCat = 'boards'; let currentType = 'board_theme';
document.querySelectorAll('#shop-tabs .tab').forEach(tab => { document.querySelectorAll('#shop-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
document.querySelectorAll('#shop-tabs .tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('#shop-tabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
currentCat = tab.dataset.cat; currentType = tab.dataset.cat;
loadShopItems(currentCat); loadShopItems(currentType);
}); });
}); });
...@@ -67,11 +67,11 @@ document.addEventListener('DOMContentLoaded', async () => { ...@@ -67,11 +67,11 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('shop-gems').textContent = profileData.profile.gems || 0; document.getElementById('shop-gems').textContent = profileData.profile.gems || 0;
} }
loadShopItems(currentCat); loadShopItems(currentType);
}); });
async function loadShopItems(category) { async function loadShopItems(type) {
const data = await App.fetch('/api/shop?category=' + category); const data = await App.fetch('/api/shop?type=' + type);
const container = document.getElementById('shop-items'); const container = document.getElementById('shop-items');
if (!data || !data.items || data.items.length === 0) { if (!data || !data.items || data.items.length === 0) {
...@@ -81,23 +81,25 @@ async function loadShopItems(category) { ...@@ -81,23 +81,25 @@ async function loadShopItems(category) {
container.innerHTML = data.items.map(item => { container.innerHTML = data.items.map(item => {
const owned = item.owned ? ' style="opacity:0.6;"' : ''; const owned = item.owned ? ' style="opacity:0.6;"' : '';
const currency = item.currency === 'gems' ? 'جوهرة' : 'عملة'; const hasGems = item.price_gems && item.price_gems > 0;
const currIcon = item.currency === 'gems' ? 'gem' : 'coin'; const hasCoins = item.price_coins && item.price_coins > 0;
const currColor = item.currency === 'gems' ? 'var(--purple)' : 'var(--gold)'; const rarityColors = { common: 'var(--text-muted)', uncommon: 'var(--green)', rare: 'var(--cyan)', epic: 'var(--purple)', legendary: 'var(--gold)' };
const rarityColor = rarityColors[item.rarity] || 'var(--text-muted)';
return ` return `
<div class="card card-hover"${owned}> <div class="card card-hover"${owned}>
<div class="card-body" style="display:flex;align-items:center;gap:16px;"> <div class="card-body" style="display:flex;align-items:center;gap:16px;">
<div class="avatar" style="background:var(--bg-3);"> <div class="avatar" style="background:var(--bg-3);border:2px solid ${rarityColor};">
<svg class="icon-lg" style="color:var(--cyan)"><use href="/public/icons/sprite.svg#icon-${item.icon || 'star'}"></use></svg> ${item.preview_url ? `<img src="${item.preview_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;" />` : `<svg class="icon-lg" style="color:${rarityColor}"><use href="/public/icons/sprite.svg#icon-star"></use></svg>`}
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<p style="font-size:15px;font-weight:600;">${item.name}</p> <p style="font-size:15px;font-weight:600;">${item.name_ar || item.name}</p>
<p class="text-muted text-xs">${item.description || ''}</p> <p class="text-muted text-xs">${item.description || ''}</p>
<span class="text-xs" style="color:${rarityColor}">${item.rarity}</span>
</div> </div>
${item.owned ? '<span class="badge badge-success">مملوك</span>' : ` ${item.owned ? (item.equipped ? '<span class="badge badge-success">مُجهّز</span>' : `<button class="btn btn-sm btn-outline" onclick="equipItem('${item.id}')">تجهيز</button>`) : `
<button class="btn btn-sm btn-gold" onclick="buyItem('${item.id}')"> <button class="btn btn-sm btn-gold" onclick="buyItem('${item.id}')">
<svg class="icon-sm" style="color:${currColor}"><use href="/public/icons/sprite.svg#icon-${currIcon}"></use></svg> <svg class="icon-sm" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-coin"></use></svg>
${item.price} ${item.price_coins || 0}
</button> </button>
`} `}
</div> </div>
...@@ -124,6 +126,20 @@ async function buyItem(itemId) { ...@@ -124,6 +126,20 @@ async function buyItem(itemId) {
App.toast(res?.error || 'خطأ في الشراء', 'error'); App.toast(res?.error || 'خطأ في الشراء', 'error');
} }
} }
async function equipItem(itemId) {
const res = await App.fetch('/api/shop', {
method: 'POST',
body: JSON.stringify({ action: 'equip', item_id: itemId, equip: true })
});
if (res && res.ok) {
App.toast('تم التجهيز!', 'success');
const active = document.querySelector('#shop-tabs .tab.active');
loadShopItems(active.dataset.cat);
} else {
App.toast(res?.error || 'خطأ في التجهيز', 'error');
}
}
</script> </script>
<?php require __DIR__ . '/../includes/footer.php'; ?> <?php require __DIR__ . '/../includes/footer.php'; ?>
...@@ -2,16 +2,18 @@ ...@@ -2,16 +2,18 @@
<?php <?php
$currentRoute = $_GET['route'] ?? ''; $currentRoute = $_GET['route'] ?? '';
$bottomItems = [ $bottomItems = [
['/', 'icon-home', 'الرئيسية'], ['/', 'icon-home', 'الرئيسية', null],
['/games', 'icon-games', 'العاب'], ['/games', 'icon-games', 'العاب', null],
['/tournaments', 'icon-trophy', 'بطولات'], ['/tournaments', 'icon-trophy', 'بطولات', 'tournaments_enabled'],
['/friends', 'icon-friends', 'اجتماعي'], ['/friends', 'icon-friends', 'اجتماعي', null],
['/profile', 'icon-profile', 'حسابي'], ['/profile', 'icon-profile', 'حسابي', null],
]; ];
foreach ($bottomItems as $item): foreach ($bottomItems as $item):
$href = $item[0]; $href = $item[0];
$icon = $item[1]; $icon = $item[1];
$label = $item[2]; $label = $item[2];
$flag = $item[3];
if ($flag && !is_feature_enabled($flag)) continue;
$route = trim($href, '/'); $route = trim($href, '/');
$isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route); $isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route);
?> ?>
......
...@@ -3,22 +3,24 @@ ...@@ -3,22 +3,24 @@
<?php <?php
$currentRoute = $_GET['route'] ?? ''; $currentRoute = $_GET['route'] ?? '';
$navItems = [ $navItems = [
['/', 'icon-home', 'الرئيسية'], ['/', 'icon-home', 'الرئيسية', null],
['/games', 'icon-games', 'العاب'], ['/games', 'icon-games', 'العاب', null],
['/puzzles', 'icon-puzzle', 'تمارين'], ['/puzzles', 'icon-puzzle', 'تمارين', null],
['/tournaments', 'icon-trophy', 'بطولات'], ['/tournaments', 'icon-trophy', 'بطولات', 'tournaments_enabled'],
['/leaderboard', 'icon-leaderboard', 'متصدرون'], ['/leaderboard', 'icon-leaderboard', 'متصدرون', null],
['/friends', 'icon-friends', 'اجتماعي'], ['/friends', 'icon-friends', 'اجتماعي', null],
['/orgs', 'icon-org', 'اندية'], ['/orgs', 'icon-org', 'اندية', null],
['/shop', 'icon-shop', 'متجر'], ['/shop', 'icon-shop', 'متجر', 'cosmetics_shop_enabled'],
['/achievements', 'icon-star', 'انجازات'], ['/achievements', 'icon-star', 'انجازات', null],
['/profile', 'icon-profile', 'حسابي'], ['/profile', 'icon-profile', 'حسابي', null],
['/settings', 'icon-settings', 'اعدادات'], ['/settings', 'icon-settings', 'اعدادات', null],
]; ];
foreach ($navItems as $item): foreach ($navItems as $item):
$href = $item[0]; $href = $item[0];
$icon = $item[1]; $icon = $item[1];
$label = $item[2]; $label = $item[2];
$flag = $item[3];
if ($flag && !is_feature_enabled($flag)) continue;
$route = trim($href, '/'); $route = trim($href, '/');
$isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route); $isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route);
?> ?>
......
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