- 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
278 lines
15 KiB
PHP
278 lines
15 KiB
PHP
<?= $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' => '2000–2009', '2010s' => '2010–2019', '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 A–Z',
|
||
'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() ?>
|