Files
ggdeals/app/Views/games.php
T
deepseek fff7a4b9b9 GG.deals clone: CodeIgniter 4 + MongoDB game price tracker
- Full gg.deals-style UI: deal feed with filters, game pages with price
  history charts, rankings, vouchers, news, prepaids, subscriptions,
  bundles, franchises, wishlist, API docs
- Real data crawlers:
  * gg:crawl (Steam Store API + GOG Catalog + Epic free games)
  * gg:catalog (252K game slugs from gg.deals sitemaps)
  * gg:ggdeals (per-game prices via FlareSolverr session, JSON-LD parsing)
  * gg:seed / gg:crawl-covers (demo seed + Steam CDN cover art)
- 24/7 self-updating scheduler (scripts/scheduler_loop.php, lockfile-protected)
- Tailwind v4 dark/light theme, Alpine.js, Chart.js
2026-08-23 07:06:02 +00:00

278 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?= $this->extend('layout') ?>
<?= $this->section('content') ?>
<?php
$storesMap = [];
foreach ($stores as $s) {
$storesMap[$s['code']] = $s;
}
$officialStores = array_filter($stores, static fn ($s) => $s['kind'] === 'official');
$keyshopStores = array_filter($stores, static fn ($s) => $s['kind'] === 'keyshop');
$selectedStores = $filters['stores'];
$selectedGenres = $filters['genres'] ?? [];
$anyFilter = $selectedStores !== [] || $filters['kind'] !== '' || $filters['min_price'] !== ''
|| $filters['max_price'] !== '' || $filters['min_cut'] > 0 || $filters['hl_only'] || $filters['q'] !== ''
|| $filters['price_tier'] !== '' || $filters['discount_tier'] !== '' || $filters['deal_rating'] !== ''
|| $filters['release_range'] !== '' || $selectedGenres !== [];
$genreQuery = $activeGenre !== '' ? '?genre=' . urlencode($activeGenre) : '';
?>
<!-- ======= page head ======= -->
<div class="flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<h1 class="text-2xl font-extrabold text-white tracking-tight">
<?= $activeGenreName !== '' ? ucwords($activeGenreName) . ' games' : 'Popular video games' ?>
</h1>
<p class="text-sm text-muted mt-1">
<span class="text-zinc-300 font-semibold"><?= number_format($total) ?></span> games with deals ·
<span class="text-zinc-300 font-semibold"><?= number_format($stats['games']) ?></span> games in database ·
<span class="text-zinc-300 font-semibold"><?= $stats['stores'] ?></span> stores
</p>
</div>
</div>
<!-- ======= main grid ======= -->
<div class="grid lg:grid-cols-[290px_1fr] gap-6 items-start">
<!-- ============ filters sidebar ============ -->
<form id="filters" method="get" action="/games<?= $activeGenre !== '' ? '/' . urlencode($activeGenre) : '' ?>" x-data="{ cut: <?= (int) $filters['min_cut'] ?>, genreSearch: '' }"
class="lg:sticky lg:top-24 bg-panel border border-line rounded-xl overflow-hidden">
<input type="hidden" name="q" value="<?= esc($filters['q']) ?>">
<div class="px-4 py-3 border-b border-line flex items-center justify-between">
<span class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Filters</span>
<?php if ($anyFilter || $activeGenre !== ''): ?>
<a href="/games" class="text-xs text-muted hover:text-accent">Reset</a>
<?php endif; ?>
</div>
<div class="p-4 space-y-5" @change="$root.submit()">
<!-- deal type -->
<div>
<div class="filter-label">Deal type</div>
<div class="grid grid-cols-3 gap-1 bg-page rounded-lg p-1 mt-2">
<?php foreach (['' => 'All deals', 'official' => 'Official', 'keyshop' => 'Keyshops'] as $k => $label): ?>
<label class="cursor-pointer">
<input type="radio" name="kind" value="<?= $k ?>" class="peer sr-only" <?= $filters['kind'] === $k ? 'checked' : '' ?>>
<span class="block text-center text-xs font-semibold py-1.5 rounded-md text-muted peer-checked:bg-card peer-checked:text-accent hover:text-zinc-200 transition-colors"><?= $label ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<!-- price tier presets -->
<div>
<div class="filter-label">Price</div>
<div class="flex flex-wrap gap-1.5 mt-2">
<?php
$priceTiers = ['' => 'Any', '2' => 'Under $2', '5' => 'Under $5', '10' => 'Under $10', '15' => 'Under $15', '20' => 'Under $20', '30' => 'Under $30'];
foreach ($priceTiers as $v => $l): ?>
<a href="<?= qs_url(['price_tier' => $v]) ?>"
class="preset-chip <?= (string) ($filters['price_tier'] ?? '') === $v ? 'chip-active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
<div class="flex items-center gap-2 mt-2">
<input type="number" name="min_price" min="0" step="0.5" placeholder="from" value="<?= esc($filters['min_price']) ?>"
class="filter-input">
<span class="text-muted text-xs">—</span>
<input type="number" name="max_price" min="0" step="0.5" placeholder="to" value="<?= esc($filters['max_price']) ?>"
class="filter-input">
</div>
</div>
<!-- discount tier presets -->
<div>
<div class="filter-label">Discount</div>
<div class="flex flex-wrap gap-1.5 mt-2">
<?php
$discountTiers = ['' => 'Any', '50' => '≥50%', '66' => '≥66%', '75' => '≥75%', '80' => '≥80%', '85' => '≥85%', '90' => '≥90%'];
foreach ($discountTiers as $v => $l): ?>
<a href="<?= qs_url(['discount_tier' => $v]) ?>"
class="preset-chip <?= (string) ($filters['discount_tier'] ?? '') === $v ? 'chip-active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
<div class="mt-2">
<div class="filter-label flex justify-between">
<span>Minimum discount</span>
<span class="text-accent font-bold" x-text="cut + '%'"></span>
</div>
<input type="range" name="min_cut" min="0" max="90" step="5" x-model.number="cut"
class="gg-range mt-3 w-full">
</div>
</div>
<!-- deal rating -->
<div>
<div class="filter-label">Deal rating</div>
<div class="flex flex-wrap gap-1.5 mt-2">
<?php
$ratings = ['' => 'Any', 'hot' => '🔥 Hot', 'good' => 'Good', 'fair' => 'Fair'];
foreach ($ratings as $v => $l): ?>
<a href="<?= qs_url(['deal_rating' => $v]) ?>"
class="preset-chip <?= (string) ($filters['deal_rating'] ?? '') === $v ? 'chip-active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
</div>
<!-- release year -->
<div>
<div class="filter-label">Release year</div>
<div class="flex flex-wrap gap-1.5 mt-2">
<?php
$years = ['' => 'Any', 'pre2000' => 'Before 2000', '2000s' => '20002009', '2010s' => '20102019', '2020s' => '2020+'];
foreach ($years as $v => $l): ?>
<a href="<?= qs_url(['release_range' => $v]) ?>"
class="preset-chip <?= (string) ($filters['release_range'] ?? '') === $v ? 'chip-active' : '' ?>"><?= $l ?></a>
<?php endforeach; ?>
</div>
</div>
<!-- genres -->
<div>
<div class="filter-label">Genres</div>
<div class="relative mt-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" x-model="genreSearch" placeholder="Search genres…"
class="filter-input pl-8">
</div>
<div class="mt-2 space-y-0.5 max-h-52 overflow-y-auto gg-scroll pr-1">
<?php foreach ($genres as $slug => $g):
$gl = mb_strtolower($g['name']); ?>
<label class="store-row" x-show="!genreSearch || '<?= $gl ?>'.includes(genreSearch.toLowerCase())">
<input type="checkbox" name="genres[]" value="<?= esc($g['name']) ?>" <?= in_array($g['name'], $selectedGenres, true) ? 'checked' : '' ?> class="gg-check">
<span class="text-xs text-zinc-300 truncate flex-1"><?= esc($g['name']) ?></span>
<span class="text-[10px] text-muted"><?= $g['count'] ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<!-- historical low only -->
<label class="flex items-center gap-2.5 cursor-pointer select-none">
<input type="checkbox" name="hl_only" value="1" <?= $filters['hl_only'] ? 'checked' : '' ?> class="gg-check">
<span class="text-xs text-zinc-300">Only all-time low offers</span>
</label>
<!-- stores -->
<div>
<div class="filter-label">Official stores</div>
<div class="mt-2 space-y-0.5 max-h-56 overflow-y-auto gg-scroll pr-1">
<?php foreach ($officialStores as $s): ?>
<label class="store-row">
<input type="checkbox" name="stores[]" value="<?= $s['code'] ?>" <?= in_array($s['code'], $selectedStores, true) ? 'checked' : '' ?> class="gg-check">
<?= view('partials/store_badge', ['store' => $s]) ?>
<span class="text-xs text-zinc-300 truncate flex-1"><?= esc($s['name']) ?></span>
<?php if (in_array($s['code'], $selectedStores, true)): ?>
<span class="text-accent">✓</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
</div>
<div>
<div class="filter-label">Keyshops</div>
<div class="mt-2 space-y-0.5 max-h-56 overflow-y-auto gg-scroll pr-1">
<?php foreach ($keyshopStores as $s): ?>
<label class="store-row">
<input type="checkbox" name="stores[]" value="<?= $s['code'] ?>" <?= in_array($s['code'], $selectedStores, true) ? 'checked' : '' ?> class="gg-check">
<?= view('partials/store_badge', ['store' => $s]) ?>
<span class="text-xs text-zinc-300 truncate flex-1"><?= esc($s['name']) ?></span>
<?php if (in_array($s['code'], $selectedStores, true)): ?>
<span class="text-accent">✓</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
</div>
<button type="submit" class="w-full bg-accent text-accent-ink font-bold text-sm py-2.5 rounded-lg hover:bg-accent/90 transition-colors">
Apply filters
</button>
</div>
</form>
<!-- ============ feed ============ -->
<div class="min-w-0">
<!-- tabs + sort -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex gap-1 bg-panel border border-line rounded-lg p-1">
<?php
$tabs = [
'trending' => 'Best deals',
'rating' => 'Deal rating',
'cut' => 'Biggest discount',
'hl' => 'Cheapest ever',
'recent' => 'New releases',
];
foreach ($tabs as $key => $label): ?>
<a href="<?= qs_url(['sort' => $key, 'page' => null]) ?>"
class="px-3 py-1.5 text-xs font-semibold rounded-md transition-colors <?= ($filters['sort'] === $key || ($key === 'trending' && $filters['sort'] === '')) ? 'bg-card text-accent' : 'text-muted hover:text-zinc-200' ?>">
<?= $label ?>
</a>
<?php endforeach; ?>
</div>
<div class="flex items-center gap-2 text-xs text-muted">
<span class="hidden sm:inline">Sort by</span>
<select name="sort" form="filters" onchange="document.getElementById('filters').submit()"
class="bg-card border border-line rounded-lg px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-accent/60">
<?php
$sortOptions = $tabs + [
'price' => 'Lowest price',
'title' => 'Title AZ',
'metascore' => 'Metascore',
'length' => 'Oldest release',
'ending' => 'Ending soon',
];
foreach ($sortOptions as $key => $label): ?>
<option value="<?= $key ?>" <?= $filters['sort'] === $key ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- rows -->
<div class="space-y-2.5">
<?php if ($feed === []): ?>
<div class="bg-card border border-line rounded-xl p-10 text-center">
<div class="text-4xl mb-3">🕹️</div>
<div class="text-zinc-200 font-semibold">No games match your filters</div>
<div class="text-muted text-sm mt-1">Try widening the price range or clearing store filters.</div>
<a href="/games" class="inline-block mt-4 bg-accent text-accent-ink font-bold text-sm py-2 px-5 rounded-lg hover:bg-accent/90">Reset filters</a>
</div>
<?php endif; ?>
<?php foreach ($feed as $row): ?>
<?= view('partials/deal_row', ['row' => $row, 'stores' => $storesMap]) ?>
<?php endforeach; ?>
</div>
<!-- pagination -->
<?php if ($pages > 1): ?>
<div class="flex items-center justify-center gap-1.5 mt-6">
<?php
$window = range(max(1, $page - 3), min($pages, $page + 3));
if ($page > 1): ?>
<a href="<?= qs_url(['page' => $page - 1]) ?>" class="pg-btn">← Prev</a>
<?php endif;
if (! in_array(1, $window, true) && $pages > 1): ?>
<a href="<?= qs_url(['page' => 1]) ?>" class="pg-btn">1</a>
<span class="pg-dots">…</span>
<?php endif;
foreach ($window as $p): ?>
<a href="<?= qs_url(['page' => $p]) ?>" class="pg-btn <?= $p === $page ? 'pg-active' : '' ?>"><?= $p ?></a>
<?php endforeach;
if (! in_array($pages, $window, true)): ?>
<span class="pg-dots">…</span>
<a href="<?= qs_url(['page' => $pages]) ?>" class="pg-btn"><?= $pages ?></a>
<?php endif;
if ($page < $pages): ?>
<a href="<?= qs_url(['page' => $page + 1]) ?>" class="pg-btn">Next →</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>