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
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($pageTitle ?? 'GG.deals — best game deals') ?></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/app.css?v=<?= @filemtime(ROOTPATH . 'public/css/app.css') ?: 1 ?>">
<script src="/js/alpine.min.js" defer></script>
</head>
<body class="h-full bg-page text-zinc-200 antialiased font-sans" x-data="ggApp()">
<div class="min-h-full flex flex-col">
<!-- ======= top bar ======= -->
<div class="border-b border-line/60 bg-[#101018]">
<div class="mx-auto max-w-[1400px] px-4 flex items-center justify-between h-9 text-xs text-muted">
<div class="flex items-center gap-4">
<span class="text-accent font-semibold">PC deals</span>
<span class="hover:text-zinc-200 cursor-pointer">Console deals</span>
<span class="hover:text-zinc-200 cursor-pointer">Free games</span>
<span class="hover:text-zinc-200 cursor-pointer">Giveaways</span>
</div>
<div class="flex items-center gap-3">
<!-- region selector -->
<div class="relative" x-data="{ open: false }" @click.outside="open=false">
<button @click="open=!open" class="flex items-center gap-1 hover:text-zinc-200 transition-colors">
<span x-text="$store.settings.region"></span>
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="open" x-cloak class="absolute right-0 top-full mt-1.5 bg-card border border-line rounded-xl shadow-2xl shadow-black/50 py-1.5 w-44 max-h-72 overflow-y-auto gg-scroll z-50">
<?php
$regions = ['United States', 'United Kingdom', 'Europe', 'Australia', 'Canada', 'Germany', 'France', 'Italy', 'Netherlands', 'Poland', 'Spain', 'Sweden', 'Switzerland', 'Denmark', 'Norway', 'Finland', 'Ireland', 'Belgium', 'Brazil'];
foreach ($regions as $r): ?>
<button @click="$store.settings.setRegion('<?= $r ?>'); open=false"
class="block w-full text-left px-3.5 py-1.5 text-xs hover:bg-elev transition-colors"
:class="$store.settings.region === '<?= $r ?>' ? 'text-accent font-bold' : 'text-zinc-300'">
<?= $r ?>
</button>
<?php endforeach; ?>
</div>
</div>
<!-- currency selector -->
<div class="relative" x-data="{ open: false }" @click.outside="open=false">
<button @click="open=!open" class="flex items-center gap-1 hover:text-zinc-200 transition-colors">
<span x-text="$store.settings.currency"></span>
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="open" x-cloak class="absolute right-0 top-full mt-1.5 bg-card border border-line rounded-xl shadow-2xl shadow-black/50 py-1.5 w-28 z-50">
<?php foreach (['USD $', 'EUR €', 'GBP £', 'CAD $', 'AUD $', 'PLN zł', 'BRL R$'] as $c): ?>
<button @click="$store.settings.setCurrency('<?= $c ?>'); open=false"
class="block w-full text-left px-3.5 py-1.5 text-xs hover:bg-elev transition-colors"
:class="$store.settings.currency === '<?= $c ?>' ? 'text-accent font-bold' : 'text-zinc-300'">
<?= $c ?>
</button>
<?php endforeach; ?>
</div>
</div>
<!-- keyshops toggle -->
<button @click="$store.settings.toggleKeyshops()"
class="flex items-center gap-1.5 hover:text-zinc-200 transition-colors"
:title="$store.settings.keyshops ? 'Keyshops enabled' : 'Keyshops disabled'">
<span class="text-[10px]">Keyshops:</span>
<span x-text="$store.settings.keyshops ? 'On' : 'Off'" :class="$store.settings.keyshops ? 'text-accent' : 'text-amber-400'"></span>
</button>
<!-- theme toggle -->
<button @click="$store.settings.toggleTheme()" class="hover:text-zinc-200 transition-colors" title="Toggle theme">
<span x-text="$store.settings.theme === 'dark' ? '🌙' : '☀️'"></span>
</button>
<span class="hover:text-zinc-200 cursor-pointer">Sign in</span>
</div>
</div>
</div>
<!-- ======= header ======= -->
<header class="bg-panel border-b border-line sticky top-0 z-40">
<div class="mx-auto max-w-[1400px] px-4 py-3 flex items-center gap-6">
<a href="/" class="flex items-baseline gap-1 shrink-0 select-none">
<span class="text-2xl font-extrabold tracking-tight text-white">GG<span class="text-accent">.deals</span></span>
</a>
<!-- search -->
<div x-data="ggSearch()" class="relative flex-1 max-w-xl" @keydown.escape="open=false" @click.outside="open=false">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 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="q"
@input="suggest()"
@keydown.enter.prevent="goSearch()"
placeholder="Search for games…"
class="w-full bg-card border border-line rounded-lg pl-9 pr-4 py-2.5 text-sm placeholder:text-muted/70 focus:outline-none focus:border-accent/70 focus:ring-1 focus:ring-accent/40"
>
</div>
<div x-show="open && results.length" x-cloak
class="absolute top-full mt-2 w-full bg-card border border-line rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50">
<template x-for="r in results" :key="r.slug">
<a :href="'/game/' + r.slug" @click="open=false"
class="flex items-center gap-3 px-3 py-2.5 hover:bg-elev transition-colors">
<img :src="r.cover" class="w-16 h-[30px] object-cover rounded-sm bg-page" alt="">
<div class="flex-1 min-w-0">
<div class="text-sm text-zinc-100 truncate" x-text="r.title"></div>
<div class="text-xs text-muted">
<span x-text="r.deals"></span> deals · all-time low
<span class="text-accent font-semibold" x-text="'$' + Number(r.hl).toFixed(2)"></span>
</div>
</div>
<svg class="w-4 h-4 text-muted" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
</a>
</template>
</div>
</div>
<nav class="hidden lg:flex items-center gap-0.5 text-sm font-semibold">
<?php
$navItems = [
'/' => 'Deals',
'/news' => 'News',
'/rankings' => 'Rankings',
'/games' => 'Games',
'/prepaids' => 'Prepaids',
'/vouchers' => 'Vouchers',
'/stores' => 'Stores',
'/api' => 'API',
'/faq' => 'FAQ',
];
$currentPath = '/' . ltrim(uri_string(), '/');
if ($currentPath === '/') {
$currentPath = '/';
}
foreach ($navItems as $url => $label):
$active = $url === '/'
? ($currentPath === '/' || str_starts_with($currentPath, '/deals'))
: ($currentPath === $url || str_starts_with($currentPath, $url . '/'));
?>
<a href="<?= $url ?>" class="px-3 py-2 rounded-lg hover:bg-elev <?= $active ? 'text-accent' : 'text-zinc-300' ?>"><?= $label ?></a>
<?php endforeach; ?>
</nav>
<a href="/wishlist" class="hidden sm:flex items-center gap-2 bg-card border border-line rounded-lg px-3 py-2.5 text-sm text-zinc-300 hover:border-accent/50 transition-colors relative">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
Wishlist
<span x-show="$store.gg.wishlist.length" x-cloak
class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-accent-ink text-[10px] font-extrabold flex items-center justify-center"
x-text="$store.gg.wishlist.length"></span>
</a>
<button class="hidden sm:flex items-center gap-2 bg-card border border-line rounded-lg px-3 py-2.5 text-sm text-zinc-300 hover:border-accent/50 transition-colors">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 8a12 12 0 0 1 12 0M8.5 12a8 8 0 0 1 7 0M11 16a4 4 0 0 1 2 0"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>
Alerts
</button>
</div>
</header>
<!-- ======= page ======= -->
<main class="flex-1 mx-auto w-full max-w-[1400px] px-4 py-6">
<?= $this->renderSection('content') ?>
</main>
<!-- ======= footer ======= -->
<footer class="border-t border-line bg-panel mt-8">
<div class="mx-auto max-w-[1400px] px-4 py-8 grid sm:grid-cols-3 gap-8 text-sm">
<div>
<div class="text-xl font-extrabold text-white mb-2">GG<span class="text-accent">.deals</span></div>
<p class="text-muted leading-relaxed">Track the best PC game prices across official stores and keyshops. Historical lows, price history charts and deal alerts — all in one place.</p>
<p class="text-muted/60 mt-3 text-xs">Demo clone built with CodeIgniter 4, Tailwind CSS &amp; MongoDB.</p>
</div>
<div>
<div class="font-semibold text-zinc-200 mb-2">Explore</div>
<ul class="space-y-1.5 text-muted">
<li><a class="hover:text-accent" href="/">Best deals</a></li>
<li><a class="hover:text-accent" href="/deals/historical-lows">Cheapest all-time lows</a></li>
<li><a class="hover:text-accent" href="/deals/new-deals">New deals</a></li>
<li><a class="hover:text-accent" href="/rankings">Rankings</a></li>
<li><a class="hover:text-accent" href="/stores">All stores</a></li>
<li><a class="hover:text-accent" href="/vouchers">Promo codes</a></li>
<li><a class="hover:text-accent" href="/franchises">Franchises</a></li>
</ul>
</div>
<div>
<div class="font-semibold text-zinc-200 mb-2">Help &amp; Community</div>
<ul class="space-y-1.5 text-muted">
<li><a class="hover:text-accent" href="/faq">FAQ</a></li>
<li><a class="hover:text-accent" href="/page/activate-steam">How to activate Steam key</a></li>
<li><a class="hover:text-accent" href="/page/keyshops-risks">Keyshops — what to know</a></li>
<li><a class="hover:text-accent" href="/api">API</a></li>
<li><a class="hover:text-accent" href="/news/freebies">Free games</a></li>
<li><a class="hover:text-accent" href="/news/giveaways">Giveaways</a></li>
<li class="hover:text-accent cursor-pointer">Join us on Discord</li>
</ul>
</div>
</div>
<div class="border-t border-line/60 py-4 text-center text-xs text-muted/70">
© <?= date('Y') ?> GG.deals clone — a demonstration project. All prices are randomly generated demo data.
</div>
</footer>
</div>
<script>
const GG_KEY = 'gg_wishlist_v1';
document.addEventListener('alpine:init', () => {
Alpine.store('settings', {
region: localStorage.getItem('gg_region') || 'United States',
currency: localStorage.getItem('gg_currency') || 'USD $',
keyshops: localStorage.getItem('gg_keyshops') !== '0',
theme: localStorage.getItem('gg_theme') || 'dark',
init() {
this.applyTheme();
},
setRegion(r) {
this.region = r;
localStorage.setItem('gg_region', r);
},
setCurrency(c) {
this.currency = c;
localStorage.setItem('gg_currency', c);
},
toggleKeyshops() {
this.keyshops = !this.keyshops;
localStorage.setItem('gg_keyshops', this.keyshops ? '1' : '0');
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('gg_theme', this.theme);
this.applyTheme();
},
applyTheme() {
document.documentElement.classList.toggle('theme-light', this.theme === 'light');
}
});
Alpine.store('gg', {
wishlist: [],
init() {
try {
this.wishlist = JSON.parse(localStorage.getItem(GG_KEY) || '[]');
} catch (e) {
this.wishlist = [];
}
},
inWishlist(slug) {
return this.wishlist.includes(slug);
},
toggleWishlist(slug, ev) {
if (ev) ev.preventDefault();
const i = this.wishlist.indexOf(slug);
if (i >= 0) this.wishlist.splice(i, 1);
else this.wishlist.push(slug);
localStorage.setItem(GG_KEY, JSON.stringify(this.wishlist));
window.dispatchEvent(new CustomEvent('gg:wishlist', { detail: this.wishlist }));
}
});
});
function ggApp() {
return {
get wishlist() {
return Alpine.store('gg').wishlist;
},
inWishlist(slug) {
return Alpine.store('gg').inWishlist(slug);
},
toggleWishlist(slug, ev) {
Alpine.store('gg').toggleWishlist(slug, ev);
}
}
}
function ggSearch() {
return {
q: '',
open: false,
results: [],
timer: null,
suggest() {
clearTimeout(this.timer);
this.timer = setTimeout(async () => {
if (this.q.trim().length < 2) { this.open = false; this.results = []; return; }
const res = await fetch('/api/suggest?q=' + encodeURIComponent(this.q.trim()));
this.results = await res.json();
this.open = true;
}, 220);
},
goSearch() {
if (this.q.trim().length > 0) {
window.location = '/?q=' + encodeURIComponent(this.q.trim());
}
}
}
}
</script>
<?= $this->renderSection('scripts') ?>
</body>
</html>