$stores]; } if (! empty($filters['kind']) && in_array($filters['kind'], ['official', 'keyshop'], true)) { $match['kind'] = $filters['kind']; } $priceCond = []; if ($filters['min_price'] !== null && $filters['min_price'] !== '') { $priceCond['$gte'] = (float) $filters['min_price']; } if ($filters['max_price'] !== null && $filters['max_price'] !== '') { $priceCond['$lte'] = (float) $filters['max_price']; } if ($priceCond !== []) { $match['price'] = $priceCond; } // price tier presets (Under $5 / $10 / $15 / $20 / $30) $priceTier = (int) ($filters['price_tier'] ?? 0); if ($priceTier > 0) { $match['price'] = array_merge($match['price'] ?? [], ['$lte' => (float) $priceTier]); } $minCut = (int) ($filters['min_cut'] ?? 0); if ($minCut > 0) { $match['cut'] = ['$gte' => $minCut]; } // discount tier presets (At least 50/66/75/80/85/90 %) $discountTier = (int) ($filters['discount_tier'] ?? 0); if ($discountTier > 0) { $match['cut'] = ['$gte' => $discountTier]; } // preset discount thresholds $preset = (string) ($filters['preset'] ?? ''); if ($preset === 'cut75') { $match['cut'] = ['$gte' => 75]; } if ($preset === 'cut85') { $match['cut'] = ['$gte' => 85]; } // deal age window (within 24h/48h/1w/2w) — deals have no created_at, // so approximate with the expiry window (ending soon) or leave empty. $timeRange = (string) ($filters['time_range'] ?? ''); if ($timeRange !== '' && $timeRange !== 'any') { // apply on game.release_date is not right; instead ignore for now // (see post-group $match below for ending-soon handling) } $basePipeline = []; if ($match !== []) { $basePipeline[] = ['$match' => $match]; } $basePipeline[] = ['$sort' => ['price' => 1]]; $basePipeline[] = [ '$group' => [ '_id' => '$game_slug', 'best_price' => ['$first' => '$price'], 'best_store' => ['$first' => '$store_code'], 'best_kind' => ['$first' => '$kind'], 'best_cut' => ['$first' => '$cut'], 'best_hl' => ['$first' => '$historical_low'], 'best_expiry' => ['$first' => '$expiry'], 'best_deal_doc' => ['$first' => '$$ROOT'], ], ]; $basePipeline[] = [ '$lookup' => [ 'from' => 'games', 'localField' => '_id', 'foreignField' => 'slug', 'as' => 'game', ], ]; $basePipeline[] = ['$unwind' => '$game']; $basePipeline[] = [ '$addFields' => [ 'deal_rating' => [ '$cond' => [ 'if' => ['$gt' => ['$best_price', 0]], 'then' => ['$divide' => ['$game.all_time_low.price', '$best_price']], 'else' => 0, ], ], 'ends_within' => [ '$cond' => [ 'if' => ['$ne' => ['$best_expiry', null]], 'then' => ['$subtract' => ['$best_expiry', '$$NOW']], 'else' => 86400000 * 365, ], ], ], ]; if (! empty($filters['q'])) { $regex = new \MongoDB\BSON\Regex(preg_quote((string) $filters['q'], '/'), 'i'); $basePipeline[] = ['$match' => ['game.title' => $regex]]; } // genre filter (e.g. /games/{genre}) if (! empty($filters['genre'])) { $basePipeline[] = ['$match' => ['game.genres' => $filters['genre']]]; } // multi-genre filter from the sidebar (matches any selected genre) $genresFilter = $filters['genres'] ?? []; if ($genresFilter !== []) { $basePipeline[] = ['$match' => ['game.genres' => ['$in' => $genresFilter]]]; } if (! empty($filters['hl_only'])) { $basePipeline[] = ['$match' => ['best_hl' => true]]; } // deal rating filter (hot/good/fair = ratio thresholds) $ratingFilter = (string) ($filters['deal_rating'] ?? ''); if ($ratingFilter !== '' && $ratingFilter !== 'any') { $ratios = [ 'hot' => ['$gte' => 0.85], 'good' => ['$gte' => 0.6], 'fair' => ['$gte' => 0.35], ]; if (isset($ratios[$ratingFilter])) { $basePipeline[] = ['$match' => ['deal_rating' => $ratios[$ratingFilter]]]; } } // release year range $releaseRange = (string) ($filters['release_range'] ?? ''); if ($releaseRange !== '' && $releaseRange !== 'any') { $ranges = [ 'pre2000' => ['$lte' => mktime(0, 0, 0, 1, 1, 2000) * 1000], '2000s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2000) * 1000, '$lte' => mktime(0, 0, 0, 1, 1, 2010) * 1000], '2010s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2010) * 1000, '$lte' => mktime(0, 0, 0, 1, 1, 2020) * 1000], '2020s' => ['$gte' => mktime(0, 0, 0, 1, 1, 2020) * 1000], ]; if (isset($ranges[$releaseRange])) { $basePipeline[] = ['$match' => ['game.release_date' => $ranges[$releaseRange]]]; } } // preset handling if ($preset === 'hl') { $basePipeline[] = ['$match' => ['best_hl' => true]]; } if ($preset === 'ending') { $basePipeline[] = ['$match' => ['ends_within' => ['$lte' => 7 * 86400000]]]; } // wishlist-only filter: match slugs passed from the client $wishlist = $filters['wishlist'] ?? []; if ($wishlist !== []) { $basePipeline[] = ['$match' => ['game.slug' => ['$in' => $wishlist]]]; } // ---- count total (run same pipeline up to filters, then count) $countPipeline = $basePipeline; $countPipeline[] = ['$count' => 'total']; $countRes = Mongo::collection('deals')->aggregate($countPipeline)->toArray(); $total = $countRes[0]['total'] ?? 0; // ---- sort $sortStage = match ($filters['sort'] ?? 'trending') { 'price' => ['best_price' => 1], 'cut' => ['best_cut' => -1], 'recent' => ['game.release_date' => -1], 'hl' => ['game.all_time_low.price' => 1], 'rating' => ['deal_rating' => -1], 'title' => ['game.title' => 1], 'metascore' => ['game.score' => -1], 'length' => ['game.release_date' => 1], 'ending' => ['ends_within' => 1], 'hot' => ['best_cut' => -1, 'deal_rating' => -1], default => ['game.trending' => -1], }; $pipeline = $basePipeline; $pipeline[] = ['$sort' => $sortStage]; $pipeline[] = ['$skip' => ($page - 1) * $perPage]; $pipeline[] = ['$limit' => $perPage]; $rows = Mongo::collection('deals')->aggregate($pipeline)->toArray(); return [ 'rows' => $rows, 'total' => $total, 'pages' => (int) max(1, ceil($total / $perPage)), ]; } public function findBySlug(string $slug): ?array { $res = Mongo::collection('games')->findOne(['slug' => $slug]); if ($res === null) { return null; } $res['_id'] = (string) $res['_id']; return $res; } /** * Steam AppID → game lookup for the public Prices API. */ public function findByAppId(int $appid): ?array { $slug = array_search($appid, \App\Commands\CrawlCovers::APP_IDS, true); if ($slug === false) { return null; } return $this->findBySlug((string) $slug); } /** * All active deals for a game, sorted by price. */ public function dealsFor(string $slug): array { $rows = Mongo::collection('deals') ->find(['game_slug' => $slug], ['sort' => ['price' => 1]]) ->toArray(); $official = []; $keyshop = []; foreach ($rows as $r) { unset($r['_id']); if ($r['kind'] === 'official') { $official[] = $r; } else { $keyshop[] = $r; } } return ['official' => $official, 'keyshop' => $keyshop]; } /** * Daily min price per store kind for the history chart. */ public function historyFor(string $slug): array { $pipeline = [ ['$match' => ['game_slug' => $slug]], [ '$group' => [ '_id' => [ 'day' => ['$dateToString' => ['date' => '$ts', 'format' => '%Y-%m-%d']], 'kind' => '$kind', ], 'price' => ['$min' => '$price'], ], ], ['$sort' => ['_id.day' => 1]], ]; $rows = Mongo::collection('price_history')->aggregate($pipeline)->toArray(); $series = ['official' => [], 'keyshop' => []]; foreach ($rows as $r) { $series[$r['_id']['kind']][] = ['day' => $r['_id']['day'], 'price' => round($r['price'], 2)]; } return $series; } public function trending(int $limit = 12): array { $rows = Mongo::collection('games') ->find([], ['sort' => ['trending' => -1], 'limit' => $limit]) ->toArray(); foreach ($rows as &$r) { $r['_id'] = (string) $r['_id']; } return $rows; } /** * Ranking list by a numeric field (score, rating_pct, trending, etc). */ public function ranked(string $field, int $limit = 20): array { $sort = in_array($field, ['score', 'rating_pct', 'trending'], true) ? [$field => -1] : ['trending' => -1]; $rows = Mongo::collection('games') ->find([], ['sort' => $sort, 'limit' => $limit]) ->toArray(); foreach ($rows as &$r) { $r['_id'] = (string) $r['_id']; } return $rows; } /** * Genre counts for the games browser. */ public function genres(): array { $rows = Mongo::collection('games')->aggregate([ ['$unwind' => '$genres'], ['$group' => ['_id' => '$genres', 'count' => ['$sum' => 1]]], ['$sort' => ['count' => -1]], ])->toArray(); return $rows; } public function byGenre(string $genre, int $limit = 40): array { $rows = Mongo::collection('games') ->find(['genres' => $genre], ['sort' => ['trending' => -1], 'limit' => $limit]) ->toArray(); foreach ($rows as &$r) { $r['_id'] = (string) $r['_id']; } return $rows; } /** * Similar games: same genre, excluding self. */ public function similar(array $game, int $limit = 8): array { $genres = array_map('strval', $game['genres'] ?? []); if ($genres === []) { return []; } $rows = Mongo::collection('games') ->find( ['slug' => ['$ne' => $game['slug']], 'genres' => ['$in' => $genres]], ['sort' => ['trending' => -1], 'limit' => $limit] ) ->toArray(); foreach ($rows as &$r) { $r['_id'] = (string) $r['_id']; } return $rows; } public function suggest(string $q, int $limit = 8): array { $regex = new \MongoDB\BSON\Regex(preg_quote($q, '/'), 'i'); $rows = Mongo::collection('games') ->find(['title' => $regex], [ 'sort' => ['trending' => -1], 'limit' => $limit, 'projection' => ['title' => 1, 'slug' => 1, 'cover' => 1, 'all_time_low' => 1, 'best_official' => 1, 'best_keyshop' => 1, 'active_deals' => 1], ]) ->toArray(); $out = []; foreach ($rows as $r) { $out[] = [ 'title' => $r['title'], 'slug' => $r['slug'], 'cover' => $r['cover']['file'] ?? '', 'hl' => $r['all_time_low']['price'] ?? null, 'deals' => $r['active_deals'] ?? 0, ]; } return $out; } public function storeStats(): array { $pipeline = [ [ '$group' => [ '_id' => '$store_code', 'deals' => ['$sum' => 1], 'min_price' => ['$min' => '$price'], 'avg_cut' => ['$avg' => '$cut'], ], ], ]; $rows = Mongo::collection('deals')->aggregate($pipeline)->toArray(); $stores = []; foreach (Mongo::collection('stores')->find()->toArray() as $s) { $stores[$s['code']] = [ 'code' => $s['code'], 'name' => $s['name'], 'kind' => $s['kind'], 'color' => $s['color'], 'textcolor' => $s['textcolor'], 'logo' => $s['logo'] ?? '/img/stores/' . $s['code'] . '.png', 'deals' => 0, 'min_price' => null, 'avg_cut' => 0, ]; } foreach ($rows as $r) { if (isset($stores[$r['_id']])) { $stores[$r['_id']]['deals'] = $r['deals']; $stores[$r['_id']]['min_price'] = $r['min_price']; $stores[$r['_id']]['avg_cut'] = round($r['avg_cut'], 1); } } usort($stores, static fn ($a, $b) => $b['deals'] <=> $a['deals']); return array_values($stores); } public function stats(): array { $games = Mongo::collection('games')->countDocuments(); $deals = Mongo::collection('deals')->countDocuments(); return [ 'games' => $games, 'deals' => $deals, 'stores' => Mongo::collection('stores')->countDocuments(), 'hl_now' => Mongo::collection('deals')->countDocuments(['historical_low' => true]), ]; } /** * Compact deal rows for the homepage dashboard sections. * $mode: best | new | hl | ending | hot | wanted | releases */ public function dashboardDeals(string $mode, int $limit = 12): array { $sort = match ($mode) { 'new' => ['game.release_date' => -1, 'deal_rating' => -1], 'hl' => ['deal_rating' => -1], 'ending' => ['ends_within' => 1], 'hot' => ['best_cut' => -1, 'deal_rating' => -1], 'wanted' => ['game.trending' => -1], default => ['deal_rating' => -1, 'best_cut' => -1], }; $pipeline = [ ['$sort' => ['price' => 1]], [ '$group' => [ '_id' => '$game_slug', 'best_price' => ['$first' => '$price'], 'best_store' => ['$first' => '$store_code'], 'best_kind' => ['$first' => '$kind'], 'best_cut' => ['$first' => '$cut'], 'best_hl' => ['$first' => '$historical_low'], 'best_expiry' => ['$first' => '$expiry'], ], ], [ '$lookup' => [ 'from' => 'games', 'localField' => '_id', 'foreignField' => 'slug', 'as' => 'game', ], ], ['$unwind' => '$game'], [ '$addFields' => [ 'deal_rating' => [ '$cond' => [ 'if' => ['$gt' => ['$best_price', 0]], 'then' => ['$divide' => ['$game.all_time_low.price', '$best_price']], 'else' => 0, ], ], 'ends_within' => [ '$cond' => [ 'if' => ['$ne' => ['$best_expiry', null]], 'then' => ['$subtract' => ['$best_expiry', '$$NOW']], 'else' => 86400000 * 365, ], ], ], ], ]; if ($mode === 'hl') { $pipeline[] = ['$match' => ['best_hl' => true]]; } if ($mode === 'ending') { $pipeline[] = ['$match' => ['ends_within' => ['$lte' => 7 * 86400000]]]; } $pipeline[] = ['$sort' => $sort]; $pipeline[] = ['$limit' => $limit]; return Mongo::collection('deals')->aggregate($pipeline)->toArray(); } /** * Most wanted / new releases / upcoming games (game-only lists). */ public function dashboardGames(string $mode, int $limit = 12): array { $sort = match ($mode) { 'releases' => ['release_date' => -1], 'upcoming' => ['release_date' => 1], default => ['trending' => -1], }; $filter = []; if ($mode === 'upcoming') { // treat games released after 2023 as "upcoming" in our demo data $filter = ['release_date' => ['$gt' => new UTCDateTime(mktime(0, 0, 0, 1, 1, 2024) * 1000)]]; } $rows = Mongo::collection('games') ->find($filter, ['sort' => $sort, 'limit' => $limit]) ->toArray(); foreach ($rows as &$r) { $r['_id'] = (string) $r['_id']; } return $rows; } /** * Latest news posts for the homepage feed. */ public function recentNews(int $limit = 6): array { return Mongo::collection('news') ->find([], ['sort' => ['date' => -1], 'limit' => $limit]) ->toArray(); } /** * Bundles that include this game. */ public function bundlesFor(string $slug): array { return Mongo::collection('bundles') ->find(['games' => $slug], ['sort' => ['price' => 1]]) ->toArray(); } /** * Franchises containing this game. */ public function franchisesFor(string $slug): array { return Mongo::collection('franchises') ->find(['games' => $slug]) ->toArray(); } }