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:
@@ -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 & 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 & 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>
|
||||
Reference in New Issue
Block a user