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
This commit is contained in:
deepseek
2026-08-23 07:06:02 +00:00
commit fff7a4b9b9
146 changed files with 15691 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
<?= $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'];
$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'] !== '';
?>
<!-- ======= 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">
<?= $filters['preset_name'] !== '' ? esc($filters['preset_name']) : 'The best PC game deals' ?>
</h1>
<p class="text-sm text-muted mt-1">
<span class="text-zinc-300 font-semibold"><?= number_format($stats['games']) ?></span> games ·
<span class="text-zinc-300 font-semibold"><?= number_format($stats['deals']) ?></span> deals from
<span class="text-zinc-300 font-semibold"><?= $stats['stores'] ?></span> stores ·
<span class="text-accent font-semibold"><?= $stats['hl_now'] ?></span> all-time lows right now
</p>
</div>
<?php if ($filters['q'] !== ''): ?>
<div class="text-sm text-muted">
Search: <span class="text-zinc-100 font-semibold"><?= esc($filters['q']) ?></span>
<a href="<?= qs_url([], ['q']) ?>" class="ml-2 text-accent hover:underline">clear</a>
</div>
<?php endif; ?>
</div>
<!-- ======= preset tabs (gg.deals style) ======= -->
<div class="flex flex-wrap gap-1.5 mb-6">
<?php
$presetLinks = [
'' => 'Best deals',
'hot-new-deals' => '🔥 Hot new deals',
'new-deals' => 'New deals',
'historical-lows' => 'Historical lows',
'ending-soon' => 'Ending soon',
'at-least-75-off' => '≥75% off',
'at-least-85-off' => '≥85% off',
];
$currentPreset = $activePreset ?? '';
$presetKey = '';
foreach ($presets as $k => $p) {
if ($p['title'] === $currentPreset) { $presetKey = $k; break; }
}
foreach ($presetLinks as $key => $label): ?>
<a href="<?= $key === '' ? qs_url(['preset' => null], ['preset']) : '/deals/' . $key ?>"
class="px-3.5 py-2 text-xs font-bold rounded-lg transition-colors border <?= $presetKey === $key ? 'bg-accent/15 text-accent border-accent/40' : 'bg-card border-line text-muted hover:text-zinc-200 hover:border-accent/30' ?>">
<?= $label ?>
</a>
<?php endforeach; ?>
</div>
<!-- ======= trending carousel ======= -->
<section class="mb-8" x-data="{ el: null }" x-init="el = $refs.track">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">🔥 Trending deals</h2>
<div class="flex gap-1.5">
<button @click="el.scrollBy({left: -480, behavior:'smooth'})" class="w-8 h-8 rounded-lg bg-card border border-line hover:border-accent/50 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button @click="el.scrollBy({left: 480, behavior:'smooth'})" class="w-8 h-8 rounded-lg bg-card border border-line hover:border-accent/50 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
<div class="relative -mx-1 px-1">
<div x-ref="track" class="flex gap-3 overflow-x-auto scroll-smooth snap-x pb-1 gg-scroll">
<?php foreach ($trending as $t): ?>
<a href="/game/<?= $t['slug'] ?>" class="snap-start group shrink-0 w-[196px] rounded-xl bg-card border border-line hover:border-accent/50 overflow-hidden transition-colors">
<div class="relative">
<img src="<?= esc($t['cover']['file']) ?>" alt="<?= esc($t['title']) ?>" class="w-full h-[92px] object-cover">
<?php $tCut = (int) round((1 - $t['best_keyshop']['price'] / $t['base_price']) * 100); ?>
<?php if ($tCut > 0): ?>
<span class="cut <?= cut_class($tCut) ?> cut-badge absolute top-2 left-2">-<?= $tCut ?>%</span>
<?php endif; ?>
</div>
<div class="p-2.5">
<div class="text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($t['title']) ?></div>
<div class="mt-1 flex items-baseline justify-between">
<span class="text-accent font-extrabold"><?= money(min($t['best_official']['price'], $t['best_keyshop']['price'])) ?></span>
<span class="text-[11px] text-muted line-through"><?= money($t['base_price']) ?></span>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- ======= main grid ======= -->
<div class="grid lg:grid-cols-[290px_1fr] gap-6 items-start">
<!-- ============ filters sidebar ============ -->
<form id="filters" method="get" action="/" x-data="{ cut: <?= (int) $filters['min_cut'] ?> }"
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): ?>
<a href="/" 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 (gg.deals style) -->
<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>
<!-- 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>
<!-- wishlist only -->
<div class="flex items-center gap-2.5">
<input type="checkbox" id="wishlist-filter" class="gg-check"
:checked="$store.gg.wishlist.length > 0"
@change="if ($event.target.checked && $store.gg.wishlist.length) { window.location = '<?= qs_url(['wishlist' => '1']) ?>'; }">
<span class="text-xs text-zinc-300 cursor-pointer" @click="$refs.wlcheck.click()">Only games from wishlist</span>
</div>
<!-- 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 deals match your filters</div>
<div class="text-muted text-sm mt-1">Try widening the price range or clearing store filters.</div>
<a href="/" 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() ?>