- 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
298 lines
16 KiB
PHP
298 lines
16 KiB
PHP
<!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>
|