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,102 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">GG.deals API</h1>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Access real-time deals, discounts, bundles, and historical low prices — perfect for bots, apps, websites, side projects, and commercial platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- API key card -->
|
||||
<div class="bg-panel border border-line rounded-2xl p-6 lg:col-span-1">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-4">Get your API key</h2>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
Find the lowest prices for <span class="text-zinc-200 font-semibold"><?= number_format($stats['games']) ?> games</span> across
|
||||
<span class="text-zinc-200 font-semibold"><?= $stats['stores'] ?> stores</span>. Check historical low prices of digital games.
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex-1 bg-page border border-line rounded-lg px-3 py-2.5 font-mono text-xs text-muted truncate">demo-key-<?= substr(md5((string) time()), 0, 12) ?></div>
|
||||
<button @click="navigator.clipboard.writeText(document.querySelector('#apikey').textContent.trim()); $el.textContent='✓'"
|
||||
class="px-3 py-2.5 rounded-lg bg-accent text-accent-ink text-xs font-bold hover:bg-accent/90 transition-colors shrink-0">Copy</button>
|
||||
</div>
|
||||
<p id="apikey" class="hidden">demo-key-<?= substr(md5((string) time()), 0, 12) ?></p>
|
||||
<p class="text-xs text-muted leading-relaxed">Integrate using simple HTTP requests. Free for non-commercial use.</p>
|
||||
</div>
|
||||
|
||||
<!-- endpoint docs -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="bg-card border border-line rounded-2xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-line flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-2 h-2 rounded-full bg-accent"></span>
|
||||
<h2 class="text-sm font-bold text-zinc-200">Game Prices API</h2>
|
||||
</div>
|
||||
<span class="kind-tag kind-official">GET</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-muted mb-3">Retrieve the lowest prices by Steam App ID.</p>
|
||||
<div class="bg-page border border-line rounded-xl p-4 font-mono text-xs text-zinc-200 mb-4 break-all">
|
||||
<span class="text-accent">GET</span> /api/prices?appid=<span class="text-amber-300">1245620</span>
|
||||
</div>
|
||||
<div class="bg-page border border-line rounded-xl p-4 font-mono text-[11px] text-zinc-300 overflow-x-auto">
|
||||
<pre>{
|
||||
"game": {
|
||||
"title": "Elden Ring",
|
||||
"slug": "elden-ring",
|
||||
"steam_appid": 1245620,
|
||||
"regular_price": 59.99,
|
||||
"best_official": { "price": 40.67, "store": "fanatical", "cut": 32 },
|
||||
"best_keyshop": { "price": 32.14, "store": "eneba", "cut": 46 },
|
||||
"all_time_low": { "price": 35.99, "store": "kinguin", "date": "2026-03-24" },
|
||||
"active_deals": 11,
|
||||
"metacritic": 96
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="bg-card border border-line rounded-xl p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-lg">🔌</span>
|
||||
<span class="text-sm font-bold text-zinc-100">Browser extension</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted leading-relaxed">Instantly view GG.deals price comparisons while browsing game stores.</p>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-xl p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-lg">✉️</span>
|
||||
<span class="text-sm font-bold text-zinc-100">Suggest an endpoint</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted leading-relaxed">Write to us — we'll get back to you shortly!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- live test -->
|
||||
<div class="bg-panel border border-line rounded-2xl p-6 mb-8" x-data="{ appid: '1245620', result: null, loading: false }">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-4">Try it live</h2>
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<input x-model="appid" type="number" placeholder="Steam App ID (e.g. 1245620)"
|
||||
class="filter-input flex-1 min-w-[220px]">
|
||||
<button @click="async () => { loading = true; try { const r = await fetch('/api/prices?appid=' + appid); result = { status: r.status, body: await r.json() }; } catch(e) { result = { status: 0, body: { error: e.message } }; } loading = false; }"
|
||||
class="bg-accent text-accent-ink font-bold text-sm py-2.5 px-6 rounded-lg hover:bg-accent/90 transition-colors">
|
||||
Send request
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="loading" class="text-sm text-muted">Loading…</div>
|
||||
<template x-if="result">
|
||||
<div class="bg-page border border-line rounded-xl p-4 font-mono text-[11px]">
|
||||
<div class="mb-2">
|
||||
<span class="kind-tag" :class="result.status === 200 ? 'kind-official' : 'kind-keyshop'" x-text="'HTTP ' + result.status"></span>
|
||||
</div>
|
||||
<pre x-text="JSON.stringify(result.body, null, 2)" class="text-zinc-300 overflow-x-auto"></pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="text-xs text-muted mb-4 flex items-center gap-1.5">
|
||||
<a href="/news" class="hover:text-accent">News</a>
|
||||
<span>›</span>
|
||||
<span class="text-zinc-400">Bundles</span>
|
||||
</div>
|
||||
|
||||
<!-- bundle header -->
|
||||
<div class="bg-panel border border-line rounded-2xl p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-5">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="kind-tag kind-keyshop mb-2 inline-block">Bundle</span>
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight leading-tight"><?= esc($bundle['title']) ?></h1>
|
||||
<p class="text-sm text-muted mt-2 leading-relaxed max-w-2xl"><?= esc($bundle['desc']) ?></p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||
<span><span class="text-zinc-300 font-semibold"><?= (int) $bundle['game_count'] ?></span> games</span>
|
||||
<span>Regular value <span class="text-zinc-300 line-through"><?= money((float) $bundle['regular']) ?></span></span>
|
||||
<span>You save <span class="text-accent font-bold">-<?= (int) round((1 - (float) $bundle['price'] / (float) $bundle['regular']) * 100) ?>%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:w-[280px] shrink-0 bg-card border border-line rounded-xl p-4">
|
||||
<div class="text-[11px] font-bold uppercase tracking-wider text-muted">Bundle price</div>
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-extrabold text-accent"><?= money((float) $bundle['price']) ?></span>
|
||||
<span class="text-xs text-muted line-through"><?= money((float) $bundle['regular']) ?></span>
|
||||
</div>
|
||||
<?php $bStore = $stores[$bundle['store']] ?? null; ?>
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted">
|
||||
<?php if ($bStore): ?><?= view('partials/store_badge', ['store' => $bStore]) ?><?php endif; ?>
|
||||
<span><?= esc($bStore['name'] ?? '') ?></span>
|
||||
</div>
|
||||
<a href="https://www.google.com/search?q=<?= rawurlencode('buy ' . $bundle['title'] . ' bundle') ?>" target="_blank" rel="nofollow noopener"
|
||||
class="mt-4 block text-center bg-accent text-accent-ink font-extrabold text-sm py-3 rounded-lg hover:bg-accent/90 transition-colors">
|
||||
Get the bundle →
|
||||
</a>
|
||||
<?php if ($bundle['expires'] instanceof MongoDB\BSON\UTCDateTime): ?>
|
||||
<div class="mt-2 text-center text-[11px] text-amber-400/90">
|
||||
ends in <?= max(1, (int) floor(($bundle['expires']->toDateTime()->getTimestamp() - time()) / 86400)) ?> days
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- games in bundle -->
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">Games in this bundle</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-2.5 mb-8">
|
||||
<?php foreach ($games as $g): ?>
|
||||
<div class="flex items-center gap-3 bg-card border border-line rounded-xl p-2.5 hover:border-accent/40 transition-colors group">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="shrink-0 rounded-md overflow-hidden ring-1 ring-line">
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="" class="w-[104px] h-[49px] object-cover" loading="lazy">
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="block text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['title']) ?></a>
|
||||
<div class="text-[11px] text-muted mt-0.5">
|
||||
<span class="score-chip mr-1">MC <?= (int) $g['score'] ?></span>
|
||||
<span><?= money($g['base_price']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
CLI::error('ERROR: ' . $code);
|
||||
CLI::write($message);
|
||||
CLI::newLine();
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
// The main Exception
|
||||
CLI::write('[' . $exception::class . ']', 'light_gray', 'red');
|
||||
CLI::write($message);
|
||||
CLI::write('at ' . CLI::color(clean_path($exception->getFile()) . ':' . $exception->getLine(), 'green'));
|
||||
CLI::newLine();
|
||||
|
||||
$last = $exception;
|
||||
|
||||
while ($prevException = $last->getPrevious()) {
|
||||
$last = $prevException;
|
||||
|
||||
CLI::write(' Caused by:');
|
||||
CLI::write(' [' . $prevException::class . ']', 'red');
|
||||
CLI::write(' ' . $prevException->getMessage());
|
||||
CLI::write(' at ' . CLI::color(clean_path($prevException->getFile()) . ':' . $prevException->getLine(), 'green'));
|
||||
CLI::newLine();
|
||||
}
|
||||
|
||||
// The backtrace
|
||||
if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) {
|
||||
$backtraces = $last->getTrace();
|
||||
|
||||
if ($backtraces) {
|
||||
CLI::write('Backtrace:', 'green');
|
||||
}
|
||||
|
||||
foreach ($backtraces as $i => $error) {
|
||||
$padFile = ' '; // 4 spaces
|
||||
$padClass = ' '; // 7 spaces
|
||||
$c = str_pad($i + 1, 3, ' ', STR_PAD_LEFT);
|
||||
|
||||
if (isset($error['file'])) {
|
||||
$filepath = clean_path($error['file']) . ':' . $error['line'];
|
||||
|
||||
CLI::write($c . $padFile . CLI::color($filepath, 'yellow'));
|
||||
} else {
|
||||
CLI::write($c . $padFile . CLI::color('[internal function]', 'yellow'));
|
||||
}
|
||||
|
||||
$function = '';
|
||||
|
||||
if (isset($error['class'])) {
|
||||
$type = ($error['type'] === '->') ? '()' . $error['type'] : $error['type'];
|
||||
$function .= $padClass . $error['class'] . $type . $error['function'];
|
||||
} elseif (! isset($error['class']) && isset($error['function'])) {
|
||||
$function .= $padClass . $error['function'];
|
||||
}
|
||||
|
||||
$args = implode(', ', array_map(static fn ($value): string => match (true) {
|
||||
is_object($value) => 'Object(' . $value::class . ')',
|
||||
is_array($value) => $value !== [] ? '[...]' : '[]',
|
||||
$value === null => 'null', // return the lowercased version
|
||||
default => var_export($value, true),
|
||||
}, array_values($error['args'] ?? [])));
|
||||
|
||||
$function .= '(' . $args . ')';
|
||||
|
||||
CLI::write($function);
|
||||
CLI::newLine();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
// On the CLI, we still want errors in productions
|
||||
// so just use the exception template.
|
||||
include __DIR__ . '/error_exception.php';
|
||||
@@ -0,0 +1,194 @@
|
||||
:root {
|
||||
--main-bg-color: #fff;
|
||||
--main-text-color: #555;
|
||||
--dark-text-color: #222;
|
||||
--light-text-color: #c7c7c7;
|
||||
--brand-primary-color: #DC4814;
|
||||
--light-bg-color: #ededee;
|
||||
--dark-bg-color: #404040;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background: var(--main-bg-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
color: var(--main-text-color);
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-weight: lighter;
|
||||
font-size: 3rem;
|
||||
color: var(--dark-text-color);
|
||||
margin: 0;
|
||||
}
|
||||
h1.headline {
|
||||
margin-top: 20%;
|
||||
font-size: 5rem;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
p.lead {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 75rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.header {
|
||||
background: var(--light-bg-color);
|
||||
color: var(--dark-text-color);
|
||||
margin-top: 2.17rem;
|
||||
}
|
||||
.header .container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header p {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
line-height: 2.5;
|
||||
}
|
||||
.header a {
|
||||
color: var(--brand-primary-color);
|
||||
margin-left: 2rem;
|
||||
display: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.header:hover a {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.environment {
|
||||
background: var(--brand-primary-color);
|
||||
color: var(--main-bg-color);
|
||||
text-align: center;
|
||||
padding: calc(4px + 0.2083vw);
|
||||
width: 100%;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.source {
|
||||
background: #343434;
|
||||
color: var(--light-text-color);
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 5px;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.source span.line {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.source span.line .number {
|
||||
color: #666;
|
||||
}
|
||||
.source .line .highlight {
|
||||
display: block;
|
||||
background: var(--dark-text-color);
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
.source span.highlight .number {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tabs li {
|
||||
display: inline;
|
||||
}
|
||||
.tabs a:link,
|
||||
.tabs a:visited {
|
||||
padding: 0 1rem;
|
||||
line-height: 2.7;
|
||||
text-decoration: none;
|
||||
color: var(--dark-text-color);
|
||||
background: var(--light-bg-color);
|
||||
border: 1px solid rgba(0,0,0,0.15);
|
||||
border-bottom: 0;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
.tabs a:hover {
|
||||
background: var(--light-bg-color);
|
||||
border-color: rgba(0,0,0,0.15);
|
||||
}
|
||||
.tabs a.active {
|
||||
background: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
.tab-content {
|
||||
background: var(--main-bg-color);
|
||||
border: 1px solid rgba(0,0,0,0.15);
|
||||
}
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 2rem;
|
||||
display: block;
|
||||
text-align: center;
|
||||
line-height: 3.0;
|
||||
background: #d9edf7;
|
||||
border: 1px solid #bcdff1;
|
||||
border-radius: 5px;
|
||||
color: #31708f;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
td {
|
||||
padding: 0.2rem 0.5rem 0.2rem 0;
|
||||
}
|
||||
tr:hover td {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
td pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.trace a {
|
||||
color: inherit;
|
||||
}
|
||||
.trace table {
|
||||
width: auto;
|
||||
}
|
||||
.trace tr td:first-child {
|
||||
min-width: 5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.trace td {
|
||||
background: var(--light-bg-color);
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.trace td pre {
|
||||
margin: 0;
|
||||
}
|
||||
.args {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
var tabLinks = new Array();
|
||||
var contentDivs = new Array();
|
||||
|
||||
function init()
|
||||
{
|
||||
// Grab the tab links and content divs from the page
|
||||
var tabListItems = document.getElementById('tabs').childNodes;
|
||||
console.log(tabListItems);
|
||||
for (var i = 0; i < tabListItems.length; i ++)
|
||||
{
|
||||
if (tabListItems[i].nodeName == "LI")
|
||||
{
|
||||
var tabLink = getFirstChildWithTagName(tabListItems[i], 'A');
|
||||
var id = getHash(tabLink.getAttribute('href'));
|
||||
tabLinks[id] = tabLink;
|
||||
contentDivs[id] = document.getElementById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign onclick events to the tab links, and
|
||||
// highlight the first tab
|
||||
var i = 0;
|
||||
|
||||
for (var id in tabLinks)
|
||||
{
|
||||
tabLinks[id].onclick = showTab;
|
||||
tabLinks[id].onfocus = function () {
|
||||
this.blur()
|
||||
};
|
||||
if (i == 0)
|
||||
{
|
||||
tabLinks[id].className = 'active';
|
||||
}
|
||||
i ++;
|
||||
}
|
||||
|
||||
// Hide all content divs except the first
|
||||
var i = 0;
|
||||
|
||||
for (var id in contentDivs)
|
||||
{
|
||||
if (i != 0)
|
||||
{
|
||||
console.log(contentDivs[id]);
|
||||
contentDivs[id].className = 'content hide';
|
||||
}
|
||||
i ++;
|
||||
}
|
||||
}
|
||||
|
||||
function showTab()
|
||||
{
|
||||
var selectedId = getHash(this.getAttribute('href'));
|
||||
|
||||
// Highlight the selected tab, and dim all others.
|
||||
// Also show the selected content div, and hide all others.
|
||||
for (var id in contentDivs)
|
||||
{
|
||||
if (id == selectedId)
|
||||
{
|
||||
tabLinks[id].className = 'active';
|
||||
contentDivs[id].className = 'content';
|
||||
}
|
||||
else
|
||||
{
|
||||
tabLinks[id].className = '';
|
||||
contentDivs[id].className = 'content hide';
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the browser following the link
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFirstChildWithTagName(element, tagName)
|
||||
{
|
||||
for (var i = 0; i < element.childNodes.length; i ++)
|
||||
{
|
||||
if (element.childNodes[i].nodeName == tagName)
|
||||
{
|
||||
return element.childNodes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHash(url)
|
||||
{
|
||||
var hashPos = url.lastIndexOf('#');
|
||||
return url.substring(hashPos + 1);
|
||||
}
|
||||
|
||||
function toggle(elem)
|
||||
{
|
||||
elem = document.getElementById(elem);
|
||||
|
||||
if (elem.style && elem.style['display'])
|
||||
{
|
||||
// Only works with the "style" attr
|
||||
var disp = elem.style['display'];
|
||||
}
|
||||
else if (elem.currentStyle)
|
||||
{
|
||||
// For MSIE, naturally
|
||||
var disp = elem.currentStyle['display'];
|
||||
}
|
||||
else if (window.getComputedStyle)
|
||||
{
|
||||
// For most other browsers
|
||||
var disp = document.defaultView.getComputedStyle(elem, null).getPropertyValue('display');
|
||||
}
|
||||
|
||||
// Toggle the state of the "display" style
|
||||
elem.style.display = disp == 'block' ? 'none' : 'block';
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= lang('Errors.badRequest') ?></title>
|
||||
|
||||
<style>
|
||||
div.logo {
|
||||
height: 200px;
|
||||
width: 155px;
|
||||
display: inline-block;
|
||||
opacity: 0.08;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
left: 50%;
|
||||
margin-left: -73px;
|
||||
}
|
||||
body {
|
||||
height: 100%;
|
||||
background: #fafafa;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #777;
|
||||
font-weight: 300;
|
||||
}
|
||||
h1 {
|
||||
font-weight: lighter;
|
||||
letter-spacing: normal;
|
||||
font-size: 3rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
color: #222;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1024px;
|
||||
margin: 5rem auto;
|
||||
padding: 2rem;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
pre {
|
||||
white-space: normal;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
code {
|
||||
background: #fafafa;
|
||||
border: 1px solid #efefef;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid #efefef;
|
||||
padding: 1em 2em 0 2em;
|
||||
font-size: 85%;
|
||||
color: #999;
|
||||
}
|
||||
a:active,
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #dd4814;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>400</h1>
|
||||
|
||||
<p>
|
||||
<?php if (ENVIRONMENT !== 'production') : ?>
|
||||
<?= nl2br(esc($message)) ?>
|
||||
<?php else : ?>
|
||||
<?= lang('Errors.sorryBadRequest') ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= lang('Errors.pageNotFound') ?></title>
|
||||
|
||||
<style>
|
||||
div.logo {
|
||||
height: 200px;
|
||||
width: 155px;
|
||||
display: inline-block;
|
||||
opacity: 0.08;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
left: 50%;
|
||||
margin-left: -73px;
|
||||
}
|
||||
body {
|
||||
height: 100%;
|
||||
background: #fafafa;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #777;
|
||||
font-weight: 300;
|
||||
}
|
||||
h1 {
|
||||
font-weight: lighter;
|
||||
letter-spacing: normal;
|
||||
font-size: 3rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
color: #222;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1024px;
|
||||
margin: 5rem auto;
|
||||
padding: 2rem;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
pre {
|
||||
white-space: normal;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
code {
|
||||
background: #fafafa;
|
||||
border: 1px solid #efefef;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid #efefef;
|
||||
padding: 1em 2em 0 2em;
|
||||
font-size: 85%;
|
||||
color: #999;
|
||||
}
|
||||
a:active,
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #dd4814;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>404</h1>
|
||||
|
||||
<p>
|
||||
<?php if (ENVIRONMENT !== 'production') : ?>
|
||||
<?= nl2br(esc($message)) ?>
|
||||
<?php else : ?>
|
||||
<?= lang('Errors.sorryCannotFind') ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
use CodeIgniter\HTTP\Header;
|
||||
use CodeIgniter\CodeIgniter;
|
||||
|
||||
$errorId = uniqid('error', true);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
<title><?= esc($title) ?></title>
|
||||
<style>
|
||||
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<?= file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.js') ?>
|
||||
</script>
|
||||
</head>
|
||||
<body onload="init()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="environment">
|
||||
Displayed at <?= esc(date('H:i:s')) ?> —
|
||||
PHP: <?= esc(PHP_VERSION) ?> —
|
||||
CodeIgniter: <?= esc(CodeIgniter::CI_VERSION) ?> --
|
||||
Environment: <?= ENVIRONMENT ?>
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h1>
|
||||
<p>
|
||||
<?= nl2br(esc($exception->getMessage())) ?>
|
||||
<a href="https://www.duckduckgo.com/?q=<?= urlencode($title . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $exception->getMessage())) ?>"
|
||||
rel="noreferrer" target="_blank">search →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="container">
|
||||
<p><b><?= esc(clean_path($file)) ?></b> at line <b><?= esc($line) ?></b></p>
|
||||
|
||||
<?php if (is_file($file)) : ?>
|
||||
<div class="source">
|
||||
<?= static::highlightFile($file, $line, 15); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<?php
|
||||
$last = $exception;
|
||||
|
||||
while ($prevException = $last->getPrevious()) {
|
||||
$last = $prevException;
|
||||
?>
|
||||
|
||||
<pre>
|
||||
Caused by:
|
||||
<?= esc($prevException::class), esc($prevException->getCode() ? ' #' . $prevException->getCode() : '') ?>
|
||||
|
||||
<?= nl2br(esc($prevException->getMessage())) ?>
|
||||
<a href="https://www.duckduckgo.com/?q=<?= urlencode($prevException::class . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $prevException->getMessage())) ?>"
|
||||
rel="noreferrer" target="_blank">search →</a>
|
||||
<?= esc(clean_path($prevException->getFile()) . ':' . $prevException->getLine()) ?>
|
||||
</pre>
|
||||
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) : ?>
|
||||
<div class="container">
|
||||
|
||||
<ul class="tabs" id="tabs">
|
||||
<li><a href="#backtrace">Backtrace</a></li>
|
||||
<li><a href="#server">Server</a></li>
|
||||
<li><a href="#request">Request</a></li>
|
||||
<li><a href="#response">Response</a></li>
|
||||
<li><a href="#files">Files</a></li>
|
||||
<li><a href="#memory">Memory</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- Backtrace -->
|
||||
<div class="content" id="backtrace">
|
||||
|
||||
<ol class="trace">
|
||||
<?php foreach ($trace as $index => $row) : ?>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<!-- Trace info -->
|
||||
<?php if (isset($row['file']) && is_file($row['file'])) : ?>
|
||||
<?php
|
||||
if (isset($row['function']) && in_array($row['function'], ['include', 'include_once', 'require', 'require_once'], true)) {
|
||||
echo esc($row['function'] . ' ' . clean_path($row['file']));
|
||||
} else {
|
||||
echo esc(clean_path($row['file']) . ' : ' . $row['line']);
|
||||
}
|
||||
?>
|
||||
<?php else: ?>
|
||||
{PHP internal code}
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Class/Method -->
|
||||
<?php if (isset($row['class'])) : ?>
|
||||
— <?= esc($row['class'] . $row['type'] . $row['function']) ?>
|
||||
<?php if (! empty($row['args'])) : ?>
|
||||
<?php $argsId = $errorId . 'args' . $index ?>
|
||||
( <a href="#" onclick="return toggle('<?= esc($argsId, 'attr') ?>');">arguments</a> )
|
||||
<div class="args" id="<?= esc($argsId, 'attr') ?>">
|
||||
<table cellspacing="0">
|
||||
|
||||
<?php
|
||||
$params = null;
|
||||
// Reflection by name is not available for closure function
|
||||
if (! str_ends_with($row['function'], '}')) {
|
||||
$mirror = isset($row['class']) ? new ReflectionMethod($row['class'], $row['function']) : new ReflectionFunction($row['function']);
|
||||
$params = $mirror->getParameters();
|
||||
}
|
||||
|
||||
foreach ($row['args'] as $key => $value) : ?>
|
||||
<tr>
|
||||
<td><code><?= esc(isset($params[$key]) ? '$' . $params[$key]->name : "#{$key}") ?></code></td>
|
||||
<td><pre><?= esc(print_r($value, true)) ?></pre></td>
|
||||
</tr>
|
||||
<?php endforeach ?>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
()
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! isset($row['class']) && isset($row['function'])) : ?>
|
||||
— <?= esc($row['function']) ?>()
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<!-- Source? -->
|
||||
<?php if (isset($row['file']) && is_file($row['file']) && isset($row['class'])) : ?>
|
||||
<div class="source">
|
||||
<?= static::highlightFile($row['file'], $row['line']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Server -->
|
||||
<div class="content" id="server">
|
||||
<?php foreach (['_SERVER', '_SESSION'] as $var) : ?>
|
||||
<?php
|
||||
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
|
||||
continue;
|
||||
} ?>
|
||||
|
||||
<h3>$<?= esc($var) ?></h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
|
||||
<tr>
|
||||
<td><?= esc($key) ?></td>
|
||||
<td>
|
||||
<?php if (is_string($value)) : ?>
|
||||
<?= esc($value) ?>
|
||||
<?php else: ?>
|
||||
<pre><?= esc(print_r($value, true)) ?></pre>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php endforeach ?>
|
||||
|
||||
<!-- Constants -->
|
||||
<?php $constants = get_defined_constants(true); ?>
|
||||
<?php if (! empty($constants['user'])) : ?>
|
||||
<h3>Constants</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($constants['user'] as $key => $value) : ?>
|
||||
<tr>
|
||||
<td><?= esc($key) ?></td>
|
||||
<td>
|
||||
<?php if (is_string($value)) : ?>
|
||||
<?= esc($value) ?>
|
||||
<?php else: ?>
|
||||
<pre><?= esc(print_r($value, true)) ?></pre>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Request -->
|
||||
<div class="content" id="request">
|
||||
<?php $request = service('request'); ?>
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 10em">Path</td>
|
||||
<td><?= esc($request->getUri()) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HTTP Method</td>
|
||||
<td><?= esc($request->getMethod()) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IP Address</td>
|
||||
<td><?= esc($request->getIPAddress()) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 10em">Is AJAX Request?</td>
|
||||
<td><?= $request->isAJAX() ? 'yes' : 'no' ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Is CLI Request?</td>
|
||||
<td><?= $request->isCLI() ? 'yes' : 'no' ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Is Secure Request?</td>
|
||||
<td><?= $request->isSecure() ? 'yes' : 'no' ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>User Agent</td>
|
||||
<td><?= esc($request->getUserAgent()->getAgentString()) ?></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<?php $empty = true; ?>
|
||||
<?php foreach (['_GET', '_POST', '_COOKIE'] as $var) : ?>
|
||||
<?php
|
||||
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
|
||||
continue;
|
||||
} ?>
|
||||
|
||||
<?php $empty = false; ?>
|
||||
|
||||
<h3>$<?= esc($var) ?></h3>
|
||||
|
||||
<table style="width: 100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
|
||||
<tr>
|
||||
<td><?= esc($key) ?></td>
|
||||
<td>
|
||||
<?php if (is_string($value)) : ?>
|
||||
<?= esc($value) ?>
|
||||
<?php else: ?>
|
||||
<pre><?= esc(print_r($value, true)) ?></pre>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php endforeach ?>
|
||||
|
||||
<?php if ($empty) : ?>
|
||||
|
||||
<div class="alert">
|
||||
No $_GET, $_POST, or $_COOKIE Information to show.
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $headers = $request->headers(); ?>
|
||||
<?php if (! empty($headers)) : ?>
|
||||
|
||||
<h3>Headers</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Header</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($headers as $name => $value) : ?>
|
||||
<tr>
|
||||
<td><?= esc($name, 'html') ?></td>
|
||||
<td>
|
||||
<?php
|
||||
if ($value instanceof Header) {
|
||||
echo esc($value->getValueLine(), 'html');
|
||||
} else {
|
||||
foreach ($value as $i => $header) {
|
||||
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
|
||||
}
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Response -->
|
||||
<?php
|
||||
$response = service('response');
|
||||
$response->setStatusCode(http_response_code());
|
||||
?>
|
||||
<div class="content" id="response">
|
||||
<table>
|
||||
<tr>
|
||||
<td style="width: 15em">Response Status</td>
|
||||
<td><?= esc($response->getStatusCode() . ' - ' . $response->getReasonPhrase()) ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php $headers = $response->headers(); ?>
|
||||
<?php if (! empty($headers)) : ?>
|
||||
<h3>Headers</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Header</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($headers as $name => $value) : ?>
|
||||
<tr>
|
||||
<td><?= esc($name, 'html') ?></td>
|
||||
<td>
|
||||
<?php
|
||||
if ($value instanceof Header) {
|
||||
echo esc($response->getHeaderLine($name), 'html');
|
||||
} else {
|
||||
foreach ($value as $i => $header) {
|
||||
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
|
||||
}
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div class="content" id="files">
|
||||
<?php $files = get_included_files(); ?>
|
||||
|
||||
<ol>
|
||||
<?php foreach ($files as $file) :?>
|
||||
<li><?= esc(clean_path($file)) ?></li>
|
||||
<?php endforeach ?>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Memory -->
|
||||
<div class="content" id="memory">
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Memory Usage</td>
|
||||
<td><?= esc(static::describeMemory(memory_get_usage(true))) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 12em">Peak Memory Usage:</td>
|
||||
<td><?= esc(static::describeMemory(memory_get_peak_usage(true))) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Memory Limit:</td>
|
||||
<td><?= esc(ini_get('memory_limit')) ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div> <!-- /tab-content -->
|
||||
|
||||
</div> <!-- /container -->
|
||||
<?php endif; ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
<title><?= lang('Errors.whoops') ?></title>
|
||||
|
||||
<style>
|
||||
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container text-center">
|
||||
|
||||
<h1 class="headline"><?= lang('Errors.whoops') ?></h1>
|
||||
|
||||
<p class="lead"><?= lang('Errors.weHitASnag') ?></p>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="max-w-3xl mb-8">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight mb-4">Frequently Asked Questions</h1>
|
||||
|
||||
<div class="space-y-3" x-data="{ open: null }">
|
||||
<?php
|
||||
$faqs = [
|
||||
['What is GG.deals?', 'GG.deals is a price comparison service for digital video games. We track prices across official stores and keyshop marketplaces, maintain historical price data, and help you find the best deal for any PC, PlayStation, Xbox or Switch game.'],
|
||||
['Are keyshops safe?', 'Keyshops are third-party marketplaces that resell game keys. While most keyshop sellers are legitimate, buying from them carries some risk — keys may be region-locked or purchased with stolen payment methods. We mark keyshop offers clearly and let you disable them entirely in settings.'],
|
||||
['What is a historical low?', 'A historical low (all-time low) is the lowest price ever recorded for a game on GG.deals. We highlight deals that reach this price with a special "New HL" badge so you know when a deal is truly exceptional.'],
|
||||
['How does the deal rating work?', 'The deal rating compares the current best price to the game\'s historical low. Deals within 15% of the all-time low get a "Hot" rating, within 40% get "Good", and further away get "Fair" or "High price".'],
|
||||
['What is a voucher code?', 'Voucher codes are discount codes offered by stores. We list active vouchers on the Vouchers page — copy the code and apply it at checkout to save extra on top of the listed price.'],
|
||||
['How do I activate a CD key?', 'After buying a key, open the corresponding store client (Steam, GOG, Epic, etc.), go to "Activate a Product" or "Redeem Code", and enter the key. Each platform has a dedicated tutorial on our site.'],
|
||||
['Can I import my game collection?', 'Yes! Create a free account and connect your Steam profile to automatically track which games you own, plus mark games as owned, wishlisted or ignored for better filtering.'],
|
||||
['Does GG.deals have an API?', 'Yes — our Game Prices API lets you retrieve the lowest prices by Steam App ID for any game we track. See the API page for documentation and a live demo.'],
|
||||
];
|
||||
foreach ($faqs as $i => [$q, $a]): ?>
|
||||
<div class="bg-card border border-line rounded-xl overflow-hidden">
|
||||
<button @click="open = open === <?= $i ?> ? null : <?= $i ?>" class="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-elev/50 transition-colors">
|
||||
<span class="text-[15px] font-semibold text-zinc-100"><?= esc($q) ?></span>
|
||||
<svg class="w-4 h-4 text-muted transition-transform" :class="open === <?= $i ?> ? 'rotate-180' : ''" 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 === <?= $i ?>" x-cloak class="px-5 pb-4">
|
||||
<p class="text-sm text-muted leading-relaxed"><?= esc($a) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-panel border border-line rounded-2xl p-6 max-w-3xl">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-4">Tutorials</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-2.5">
|
||||
<?php foreach (['activate-steam', 'activate-gog', 'activate-epic', 'activate-origin', 'activate-uplay', 'keyshops-risks'] as $t): ?>
|
||||
<a href="/page/<?= $t ?>" class="flex items-center gap-3 bg-card border border-line rounded-xl px-4 py-3 hover:border-accent/40 transition-colors group">
|
||||
<span class="text-lg">📖</span>
|
||||
<span class="text-sm font-semibold text-zinc-100 group-hover:text-accent"><?= ucwords(str_replace('-', ' ', $t)) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight"><?= esc($franchise['name']) ?> Franchise</h1>
|
||||
<p class="text-sm text-muted mt-1">All games in the <?= esc($franchise['name']) ?> series with current best prices.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-8">
|
||||
<?php foreach ($games as $g): ?>
|
||||
<a href="/game/<?= $g['slug'] ?>" class="group bg-card border border-line rounded-xl overflow-hidden hover:border-accent/50 transition-colors">
|
||||
<div class="relative">
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="<?= esc($g['title']) ?>" class="w-full h-[105px] object-cover group-hover:opacity-90 transition-opacity">
|
||||
<span class="absolute top-2 right-2 score-chip">MC <?= (int) $g['score'] ?></span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-sm font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['title']) ?></div>
|
||||
<div class="mt-1 flex items-center justify-between text-xs">
|
||||
<span class="text-muted"><?= money($g['base_price']) ?></span>
|
||||
<span class="text-accent font-extrabold"><?= money(min($g['best_official']['price'], $g['best_keyshop']['price'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($allFranchises !== []): ?>
|
||||
<div class="bg-panel border border-line rounded-2xl p-5">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">All franchises</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php foreach ($allFranchises as $f): ?>
|
||||
<a href="/franchise/<?= esc($f['slug']) ?>" class="chip hover:border-accent/60 transition-colors">
|
||||
<?= esc($f['name']) ?> <span class="text-muted">(<?= count($f['games']) ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Franchises</h1>
|
||||
<p class="text-sm text-muted mt-1">Browse game series and franchises.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<?php foreach ($franchises as $f): ?>
|
||||
<a href="/franchise/<?= esc($f['slug']) ?>" class="group bg-card border border-line rounded-xl p-4 hover:border-accent/50 transition-colors">
|
||||
<div class="text-[15px] font-semibold text-zinc-100 group-hover:text-accent"><?= esc($f['name']) ?></div>
|
||||
<div class="mt-1 text-xs text-muted"><?= count($f['games']) ?> games</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,601 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$hl = $game['all_time_low'];
|
||||
$hlStore = $stores[$hl['store']] ?? null;
|
||||
$allDeals = array_merge($deals['official'], $deals['keyshop']);
|
||||
$bestDeal = $allDeals ? $allDeals[array_key_first($allDeals)] : null; // already sorted by price
|
||||
$bestStore = $bestDeal ? ($stores[$bestDeal['store_code']] ?? null) : null;
|
||||
$releaseY = substr((string) bson_date($game['release_date']), -4);
|
||||
$ratingPct = (int) $game['rating_pct'];
|
||||
$ratingLabel = $ratingPct >= 90 ? 'Overwhelmingly positive' : ($ratingPct >= 75 ? 'Very positive' : ($ratingPct >= 50 ? 'Mostly positive' : 'Mixed'));
|
||||
?>
|
||||
|
||||
<!-- breadcrumbs -->
|
||||
<div class="text-xs text-muted mb-4 flex items-center gap-1.5">
|
||||
<a href="/" class="hover:text-accent">Deals</a>
|
||||
<span>›</span>
|
||||
<span class="text-zinc-400"><?= esc($game['title']) ?></span>
|
||||
</div>
|
||||
|
||||
<!-- ======= game header ======= -->
|
||||
<div class="bg-panel border border-line rounded-2xl p-5 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- cover + info -->
|
||||
<div class="flex gap-5 min-w-0 flex-1">
|
||||
<img src="<?= esc($game['cover']['file']) ?>" alt="<?= esc($game['title']) ?>"
|
||||
class="w-[220px] h-[103px] object-cover rounded-xl ring-1 ring-line shrink-0">
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight leading-tight"><?= esc($game['title']) ?></h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="score-chip" title="Critic score">MC <?= (int) $game['score'] ?></span>
|
||||
<span class="chip <?= $ratingPct >= 75 ? 'chip-green' : 'chip-amber' ?>"><?= $ratingLabel ?> · <?= $ratingPct ?>%</span>
|
||||
<span class="chip"><?= esc($releaseY) ?></span>
|
||||
<?php foreach (array_slice($game['genres'], 0, 3) as $genre): ?>
|
||||
<span class="chip"><?= esc($genre) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||
<span><span class="text-zinc-300 font-semibold"><?= count($allDeals) ?></span> active offers</span>
|
||||
<span>
|
||||
<span class="text-zinc-300 font-semibold"><?= count($deals['official']) ?></span> official store<?= count($deals['official']) !== 1 ? 's' : '' ?>
|
||||
· <span class="text-zinc-300 font-semibold"><?= count($deals['keyshop']) ?></span> keyshop<?= count($deals['keyshop']) !== 1 ? 's' : '' ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="chip hover:border-accent/60 flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-accent">🔔</span> Create price alert
|
||||
</button>
|
||||
<button @click="$store.gg.toggleWishlist('<?= $game['slug'] ?>', $event)"
|
||||
:class="$store.gg.inWishlist('<?= $game['slug'] ?>') ? 'border-accent/60 text-accent' : 'hover:border-accent/60'"
|
||||
class="chip cursor-pointer flex items-center gap-1.5 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" :fill="$store.gg.inWishlist('<?= $game['slug'] ?>') ? 'currentColor' : '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>
|
||||
<span x-text="$store.gg.inWishlist('<?= $game['slug'] ?>') ? 'In wishlist' : 'Wishlist'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- best offer panel -->
|
||||
<?php if ($bestDeal !== null): ?>
|
||||
<?php $bestRating = deal_rating((float) $hl['price'], (float) $bestDeal['price']); ?>
|
||||
<div class="lg:w-[340px] shrink-0 bg-card border border-line rounded-xl p-4 flex flex-col">
|
||||
<div class="text-[11px] font-bold uppercase tracking-wider text-muted">Best current deal</div>
|
||||
<div class="mt-2.5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-3xl font-extrabold text-accent leading-none"><?= money($bestDeal['price']) ?></span>
|
||||
<?php if ((int) $bestDeal['cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $bestDeal['cut']) ?> cut-badge">-<?= (int) $bestDeal['cut'] ?>%</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-xs text-muted line-through mt-1"><?= money($game['base_price']) ?> regular price</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center gap-1.5 justify-end">
|
||||
<?php if ($bestStore): ?><?= view('partials/store_badge', ['store' => $bestStore, 'size' => 'lg']) ?><?php endif; ?>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-300 mt-1.5"><?= esc($bestStore['name'] ?? '') ?></div>
|
||||
<div class="kind-tag <?= $bestDeal['kind'] === 'official' ? 'kind-official' : 'kind-keyshop' ?>">
|
||||
<?= $bestDeal['kind'] === 'official' ? 'Official store' : 'Keyshop' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="<?= esc($bestDeal['url']) ?>" target="_blank" rel="nofollow noopener"
|
||||
class="mt-4 block text-center bg-accent text-accent-ink font-extrabold text-sm py-3 rounded-lg hover:bg-accent/90 transition-colors">
|
||||
Go to store →
|
||||
</a>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-line/70 text-xs text-muted space-y-1">
|
||||
<div>
|
||||
All-time low:
|
||||
<span class="text-accent font-bold"><?= money($hl['price']) ?></span>
|
||||
at <span class="text-zinc-300"><?= esc($hlStore['name'] ?? '?') ?></span>
|
||||
</div>
|
||||
<div>
|
||||
Reached <span class="text-zinc-300"><?= time_ago($hl['date']) ?></span> (<?= bson_date($hl['date']) ?>)
|
||||
</div>
|
||||
<div class="pt-1"><span class="rating <?= $bestRating['cls'] ?>"><?= $bestRating['label'] ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= header price tabs (Current Prices / Historical Low) ======= -->
|
||||
<div x-data="{ priceTab: 'current' }" class="mb-6">
|
||||
<div class="flex gap-1 border-b border-line mb-4">
|
||||
<button @click="priceTab='current'" :class="priceTab==='current' ? 'text-accent border-accent' : 'text-muted border-transparent hover:text-zinc-300'"
|
||||
class="px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-colors">Current Prices</button>
|
||||
<button @click="priceTab='historical'" :class="priceTab==='historical' ? 'text-accent border-accent' : 'text-muted border-transparent hover:text-zinc-300'"
|
||||
class="px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-colors">Historical Low</button>
|
||||
</div>
|
||||
|
||||
<div x-show="priceTab==='current'" x-cloak>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- official stores low -->
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="kind-tag kind-official">Official Stores</span>
|
||||
<?php if ((int) $game['best_official']['cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $game['best_official']['cut']) ?> cut-badge">-<?= (int) $game['best_official']['cut'] ?>%</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-extrabold text-accent"><?= money($game['best_official']['price']) ?></span>
|
||||
<span class="text-xs text-muted line-through"><?= money($game['base_price']) ?></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
||||
<?php $offStore = $stores[$game['best_official']['store']] ?? null; ?>
|
||||
<?php if ($offStore): ?><?= view('partials/store_badge', ['store' => $offStore]) ?><?php endif; ?>
|
||||
<span><?= esc($offStore['name'] ?? '') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- keyshops low -->
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="kind-tag kind-keyshop">Keyshops</span>
|
||||
<?php if ((int) $game['best_keyshop']['cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $game['best_keyshop']['cut']) ?> cut-badge">-<?= (int) $game['best_keyshop']['cut'] ?>%</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-extrabold text-accent"><?= money($game['best_keyshop']['price']) ?></span>
|
||||
<span class="text-xs text-muted line-through"><?= money($game['base_price']) ?></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
||||
<?php $keyStore = $stores[$game['best_keyshop']['store']] ?? null; ?>
|
||||
<?php if ($keyStore): ?><?= view('partials/store_badge', ['store' => $keyStore]) ?><?php endif; ?>
|
||||
<span><?= esc($keyStore['name'] ?? '') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- subscriptions -->
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<span class="kind-tag kind-official mb-2 inline-block">Subscriptions</span>
|
||||
<?php if ($subscriptions !== []): ?>
|
||||
<div class="text-2xl font-extrabold text-zinc-100"><?= money((float) $subscriptions[0]['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-1"><?= esc($subscriptions[0]['name']) ?></div>
|
||||
<div class="text-[11px] text-muted/70 mt-1">included in the library</div>
|
||||
<?php else: ?>
|
||||
<div class="text-sm text-muted">Not available</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- all-time low -->
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<span class="kind-tag kind-official mb-2 inline-block">All-time low</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-extrabold text-accent"><?= money($hl['price']) ?></span>
|
||||
<span class="text-xs text-muted"><?= bson_date($hl['date']) ?></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
|
||||
<?php if ($hlStore): ?><?= view('partials/store_badge', ['store' => $hlStore]) ?><?php endif; ?>
|
||||
<span><?= esc($hlStore['name'] ?? '') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="priceTab==='historical'" x-cloak>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<div class="kind-tag kind-official mb-2 inline-block">Official Stores Low</div>
|
||||
<div class="text-2xl font-extrabold text-accent"><?= money($game['best_official']['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-1">Lowest official-store price right now</div>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-xl p-4">
|
||||
<div class="kind-tag kind-keyshop mb-2 inline-block">Keyshops Low</div>
|
||||
<div class="text-2xl font-extrabold text-accent"><?= money($game['best_keyshop']['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-1">Lowest keyshop price right now</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= tabs ======= -->
|
||||
<div x-data="gamePage()" class="mb-6">
|
||||
<div class="flex gap-1 border-b border-line mb-5">
|
||||
<button @click="tab='deals'" :class="tab==='deals' ? 'text-accent border-accent' : 'text-muted border-transparent hover:text-zinc-300'"
|
||||
class="px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-colors">
|
||||
All deals (<?= count($allDeals) ?>)
|
||||
</button>
|
||||
<button @click="tab='history'; $nextTick(() => initChart())" :class="tab==='history' ? 'text-accent border-accent' : 'text-muted border-transparent hover:text-zinc-300'"
|
||||
class="px-4 py-2.5 text-sm font-bold border-b-2 -mb-px transition-colors">
|
||||
Price history
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======= deals tab ======= -->
|
||||
<div x-show="tab==='deals'" x-cloak>
|
||||
<!-- official stores -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Official stores</h2>
|
||||
<span class="kind-tag kind-official">Verified & trusted</span>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-xl overflow-hidden">
|
||||
<?php if ($deals['official'] === []): ?>
|
||||
<div class="p-6 text-center text-sm text-muted">No official store deals right now.</div>
|
||||
<?php else: ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] uppercase tracking-wider text-muted bg-page/60">
|
||||
<th class="px-4 py-2.5 font-semibold">Store</th>
|
||||
<th class="px-4 py-2.5 font-semibold">DRM</th>
|
||||
<th class="px-4 py-2.5 font-semibold text-right">Discount</th>
|
||||
<th class="px-4 py-2.5 font-semibold text-right">Price</th>
|
||||
<th class="px-4 py-2.5 font-semibold"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line/60">
|
||||
<?php foreach ($deals['official'] as $d): $ds = $stores[$d['store_code']] ?? null; ?>
|
||||
<tr class="hover:bg-elev/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<?php if ($ds): ?><?= view('partials/store_badge', ['store' => $ds]) ?><?php endif; ?>
|
||||
<span class="font-semibold text-zinc-200"><?= esc($ds['name'] ?? '?') ?></span>
|
||||
<?php if ($d['historical_low']): ?><span class="hl-flag">★ HL</span><?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted"><?= esc($d['drm']) ?></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<?php if ((int) $d['cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $d['cut']) ?> cut-badge">-<?= (int) $d['cut'] ?>%</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted text-xs">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<span class="font-extrabold text-accent text-base"><?= money($d['price']) ?></span>
|
||||
<span class="block text-[11px] text-muted line-through"><?= money($d['regular']) ?></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<?php if ($d['expiry']): ?>
|
||||
<?php $daysLeft = max(1, (int) floor(($d['expiry']->toDateTime()->getTimestamp() - time()) / 86400)); ?>
|
||||
<span class="text-[11px] text-amber-400/90 mr-3">ends in <?= $daysLeft ?>d</span>
|
||||
<?php endif; ?>
|
||||
<a href="<?= esc($d['url']) ?>" target="_blank" rel="nofollow noopener"
|
||||
class="btn-buy">Buy</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- keyshops -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Keyshops</h2>
|
||||
<span class="kind-tag kind-keyshop">Marketplace sellers</span>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-xl overflow-hidden">
|
||||
<?php if ($deals['keyshop'] === []): ?>
|
||||
<div class="p-6 text-center text-sm text-muted">No keyshop offers right now.</div>
|
||||
<?php else: ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] uppercase tracking-wider text-muted bg-page/60">
|
||||
<th class="px-4 py-2.5 font-semibold">Store</th>
|
||||
<th class="px-4 py-2.5 font-semibold">Edition</th>
|
||||
<th class="px-4 py-2.5 font-semibold text-right">Discount</th>
|
||||
<th class="px-4 py-2.5 font-semibold text-right">Price</th>
|
||||
<th class="px-4 py-2.5 font-semibold"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line/60">
|
||||
<?php foreach ($deals['keyshop'] as $d): $ds = $stores[$d['store_code']] ?? null; ?>
|
||||
<tr class="hover:bg-elev/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<?php if ($ds): ?><?= view('partials/store_badge', ['store' => $ds]) ?><?php endif; ?>
|
||||
<span class="font-semibold text-zinc-200"><?= esc($ds['name'] ?? '?') ?></span>
|
||||
<?php if ($d['historical_low']): ?><span class="hl-flag">★ HL</span><?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">
|
||||
Global
|
||||
<?php if ($d['regional']): ?>
|
||||
<span class="text-amber-400/90">· ⚠ region-locked</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<?php if ((int) $d['cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $d['cut']) ?> cut-badge">-<?= (int) $d['cut'] ?>%</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted text-xs">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<span class="font-extrabold text-accent text-base"><?= money($d['price']) ?></span>
|
||||
<span class="block text-[11px] text-muted line-through"><?= money($d['regular']) ?></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="<?= esc($d['url']) ?>" target="_blank" rel="nofollow noopener" class="btn-buy">Buy</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="px-4 py-2.5 text-[11px] text-muted/80 bg-page/40 border-t border-line/60">
|
||||
Keyshop offers are sold by marketplace sellers. GG.deals is not responsible for their content.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= history tab ======= -->
|
||||
<div x-show="tab==='history'" x-cloak class="bg-panel border border-line rounded-2xl p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider"><?= esc($game['title']) ?> price history</h2>
|
||||
<p class="text-xs text-muted mt-1">Lowest recorded price per day</p>
|
||||
</div>
|
||||
<div class="flex gap-1 bg-card border border-line rounded-lg p-1">
|
||||
<template x-for="r in [['90','3M'],['180','6M'],['365','1Y'],['0','All']]" :key="r[0]">
|
||||
<button @click="range=Number(r[0]); setRange()"
|
||||
:class="range===Number(r[0]) ? 'bg-elev text-accent' : 'text-muted hover:text-zinc-200'"
|
||||
class="px-3 py-1.5 text-xs font-semibold rounded-md transition-colors" x-text="r[1]"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[360px]"><canvas id="historyChart"></canvas></div>
|
||||
<div class="mt-4 grid sm:grid-cols-3 gap-3 text-sm">
|
||||
<div class="bg-card border border-line rounded-lg p-3">
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">All-time low</div>
|
||||
<div class="text-accent font-extrabold text-lg mt-0.5"><?= money($hl['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-0.5"><?= esc($hlStore['name'] ?? '') ?> · <?= time_ago($hl['date']) ?></div>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-lg p-3">
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">Best official store now</div>
|
||||
<div class="text-zinc-100 font-extrabold text-lg mt-0.5"><?= money($game['best_official']['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-0.5"><?= esc($stores[$game['best_official']['store']]['name'] ?? '') ?></div>
|
||||
</div>
|
||||
<div class="bg-card border border-line rounded-lg p-3">
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">Best keyshop now</div>
|
||||
<div class="text-zinc-100 font-extrabold text-lg mt-0.5"><?= money($game['best_keyshop']['price']) ?></div>
|
||||
<div class="text-xs text-muted mt-0.5"><?= esc($stores[$game['best_keyshop']['store']]['name'] ?? '') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= about / requirements ======= -->
|
||||
<div class="grid lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="lg:col-span-2 bg-panel border border-line rounded-2xl p-5">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">About <?= esc($game['title']) ?></h2>
|
||||
<p class="text-sm text-muted leading-relaxed"><?= esc($game['description'] ?? '') ?></p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="chip"><span class="text-muted">Developer:</span> <span class="text-zinc-200"><?= esc($game['developer'] ?? '—') ?></span></span>
|
||||
<span class="chip"><span class="text-muted">Publisher:</span> <span class="text-zinc-200"><?= esc($game['publisher'] ?? '—') ?></span></span>
|
||||
<span class="chip"><span class="text-muted">Age rating:</span> <span class="text-zinc-200"><?= esc($game['age_rating'] ?? '—') ?></span></span>
|
||||
<span class="chip"><span class="text-muted">Release:</span> <span class="text-zinc-200"><?= esc($releaseY) ?></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-panel border border-line rounded-2xl p-5">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">System Requirements</h2>
|
||||
<?php $reqs = $game['sys_reqs'] ?? []; ?>
|
||||
<div class="space-y-2.5 text-sm">
|
||||
<?php foreach (['os' => 'OS', 'cpu' => 'CPU', 'ram' => 'RAM', 'gpu' => 'GPU', 'storage' => 'Storage'] as $k => $label): ?>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted shrink-0"><?= $label ?></span>
|
||||
<span class="text-zinc-200 text-right"><?= esc($reqs[$k] ?? '—') ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if (! empty($game['length'])): ?>
|
||||
<div class="mt-4 pt-4 border-t border-line/60">
|
||||
<div class="text-[11px] font-bold uppercase tracking-wider text-muted mb-2">How long to beat</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div class="bg-card border border-line rounded-lg py-2"><div class="text-accent font-extrabold"><?= (int) $game['length']['main'] ?>h</div><div class="text-[10px] text-muted">Main</div></div>
|
||||
<div class="bg-card border border-line rounded-lg py-2"><div class="text-accent font-extrabold"><?= (int) $game['length']['completion'] ?>h</div><div class="text-[10px] text-muted">Completion</div></div>
|
||||
<div class="bg-card border border-line rounded-lg py-2"><div class="text-accent font-extrabold"><?= (int) $game['length']['perfect'] ?>h</div><div class="text-[10px] text-muted">100%</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= bundles ======= -->
|
||||
<?php if ($bundles !== []): ?>
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Bundles with <?= esc($game['title']) ?></h2>
|
||||
<a href="/deals" class="text-xs font-bold text-accent hover:underline">See all →</a>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<?php foreach ($bundles as $b): ?>
|
||||
<a href="/bundle/<?= esc($b['slug']) ?>" class="bg-card border border-line rounded-xl p-4 hover:border-accent/40 transition-colors group block">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="kind-tag kind-keyshop">Bundle</span>
|
||||
<span class="text-[11px] text-muted"><?= (int) $b['game_count'] ?> games</span>
|
||||
</div>
|
||||
<div class="text-[15px] font-semibold text-zinc-100 mb-1 group-hover:text-accent"><?= esc($b['title']) ?></div>
|
||||
<p class="text-xs text-muted leading-relaxed mb-3 line-clamp-2"><?= esc($b['desc']) ?></p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-accent font-extrabold"><?= money((float) $b['price']) ?></span>
|
||||
<span class="text-[11px] text-muted line-through ml-1.5"><?= money((float) $b['regular']) ?></span>
|
||||
</div>
|
||||
<span class="text-[10px] text-amber-400/90">ends in <?= $b['expires'] instanceof MongoDB\BSON\UTCDateTime ? max(1, (int) floor(($b['expires']->toDateTime()->getTimestamp() - time()) / 86400)) . 'd' : 'soon' ?></span>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ======= franchises ======= -->
|
||||
<?php if ($franchises !== []): ?>
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Series & franchises</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php foreach ($franchises as $fr): ?>
|
||||
<a href="/franchise/<?= esc($fr['slug']) ?>" class="chip hover:border-accent/60 transition-colors">
|
||||
<?= esc($fr['name']) ?> <span class="text-muted">(<?= count($fr['games']) ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ======= similar games ======= -->
|
||||
<?php if ($similar !== []): ?>
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">Similar games</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto scroll-smooth snap-x pb-1 gg-scroll -mx-1 px-1">
|
||||
<?php foreach ($similar as $sg): ?>
|
||||
<a href="/game/<?= $sg['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($sg['cover']['file']) ?>" alt="<?= esc($sg['title']) ?>" class="w-full h-[92px] object-cover">
|
||||
<span class="score-chip absolute top-2 left-2">MC <?= (int) $sg['score'] ?></span>
|
||||
</div>
|
||||
<div class="p-2.5">
|
||||
<div class="text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($sg['title']) ?></div>
|
||||
<div class="mt-1 flex items-baseline justify-between">
|
||||
<span class="text-accent font-extrabold"><?= money(min($sg['best_official']['price'], $sg['best_keyshop']['price'])) ?></span>
|
||||
<span class="text-[11px] text-muted line-through"><?= money($sg['base_price']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
window.__GG__ = {
|
||||
history: <?= json_encode($history) ?>,
|
||||
hl: <?= json_encode((float) $hl['price']) ?>,
|
||||
base: <?= json_encode((float) $game['base_price']) ?>,
|
||||
title: <?= json_encode($game['title']) ?>
|
||||
};
|
||||
</script>
|
||||
<script src="/js/chart.umd.js"></script>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
function gamePage() {
|
||||
// Chart instance lives OUTSIDE the Alpine reactive data object —
|
||||
// Alpine proxies every property access, and Chart.js's internal
|
||||
// option resolver trips the proxy into infinite recursion.
|
||||
let chart = null;
|
||||
const data = window.__GG__.history;
|
||||
const base = window.__GG__.base;
|
||||
const hl = window.__GG__.hl;
|
||||
|
||||
return {
|
||||
tab: 'deals',
|
||||
range: 0,
|
||||
setRange() {
|
||||
if (!chart) return;
|
||||
const cutoff = this.range > 0 ? new Date(Date.now() - this.range * 86400000).toISOString().slice(0, 10) : null;
|
||||
const filter = (arr) => cutoff ? arr.filter(p => p.day >= cutoff) : arr;
|
||||
|
||||
chart.data.labels = filter(data.official).map(p => p.day);
|
||||
chart.data.datasets[0].data = filter(data.official).map(p => p.price);
|
||||
chart.data.datasets[1].data = filter(data.keyshop).map(p => p.price);
|
||||
chart.update();
|
||||
},
|
||||
initChart() {
|
||||
if (chart) { this.setRange(); return; }
|
||||
const ctx = document.getElementById('historyChart');
|
||||
if (!ctx) return;
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.official.map(p => p.day),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Official stores',
|
||||
data: data.official.map(p => p.price),
|
||||
borderColor: '#8be04b',
|
||||
backgroundColor: 'rgba(139,224,75,0.08)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Keyshops',
|
||||
data: data.keyshop.map(p => p.price),
|
||||
borderColor: '#ffa657',
|
||||
backgroundColor: 'rgba(255,166,87,0.06)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'All-time low',
|
||||
data: data.official.map(() => hl),
|
||||
borderColor: 'rgba(255,255,255,0.35)',
|
||||
borderDash: [6, 5],
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Regular price',
|
||||
data: data.official.map(() => base),
|
||||
borderColor: 'rgba(157,157,180,0.25)',
|
||||
borderDash: [3, 4],
|
||||
pointRadius: 0,
|
||||
borderWidth: 1,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#b9b9c9', usePointStyle: true, boxWidth: 8 } },
|
||||
tooltip: {
|
||||
backgroundColor: '#20202d',
|
||||
borderColor: '#2e2e42',
|
||||
borderWidth: 1,
|
||||
titleColor: '#fff',
|
||||
callbacks: { label: (c) => c.dataset.label + ': $' + Number(c.parsed.y).toFixed(2) }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#71718a', maxTicksLimit: 10 },
|
||||
grid: { color: 'rgba(46,46,66,0.5)' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#71718a', callback: (v) => '$' + v },
|
||||
grid: { color: 'rgba(46,46,66,0.5)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,277 @@
|
||||
<?= $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() ?>
|
||||
@@ -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' => '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>
|
||||
|
||||
<!-- 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 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 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() ?>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$dealSections = [
|
||||
'popular' => ['Most Popular Games', 'popular', '/deals'],
|
||||
'newDeals' => ['New deals', 'new', '/deals/new-deals'],
|
||||
'bestDeals' => ['Best deals', 'hot', '/deals/hot-new-deals'],
|
||||
'histLows' => ['Historical lows', 'hl', '/deals/historical-lows'],
|
||||
'endingSoon' => ['Ending Soon', 'ending', '/deals/ending-soon'],
|
||||
];
|
||||
?>
|
||||
|
||||
<!-- ======= stats strip ======= -->
|
||||
<div class="bg-panel border border-line rounded-2xl px-5 py-4 mb-8 flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div>
|
||||
<div class="text-2xl font-extrabold text-white"><?= number_format($stats['games']) ?></div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">Games tracked</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-extrabold text-white"><?= number_format($stats['deals']) ?></div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">Deals today</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-extrabold text-white"><?= $stats['stores'] ?></div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">Stores</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-extrabold text-accent"><?= $stats['hl_now'] ?></div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-muted">All-time lows now</div>
|
||||
</div>
|
||||
<div class="ml-auto text-xs text-muted">
|
||||
Compare prices of video games from <span class="text-zinc-200 font-semibold"><?= $stats['stores'] ?> stores</span>,
|
||||
find the best game deals online. CD keys for PC games at the best price!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= deal sections ======= -->
|
||||
<?php foreach ($dealSections as $key => [$title, $mode, $link]): ?>
|
||||
<?php if (${$key} !== []): ?>
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider"><?= $title ?></h2>
|
||||
<a href="<?= $link ?>" class="text-xs font-bold text-accent hover:underline">See all →</a>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||
<?php foreach (${$key} as $d): ?>
|
||||
<?= view('partials/dash_deal', ['d' => $d, 'stores' => $stores]) ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- ======= game lists: most wanted / new releases / upcoming ======= -->
|
||||
<div class="grid lg:grid-cols-3 gap-6 mb-8">
|
||||
<?php
|
||||
$gameSections = [
|
||||
'mostWanted' => ['🔥 Most Wanted', 'wanted'],
|
||||
'newReleases' => ['🆕 New releases', 'releases'],
|
||||
'upcoming' => ['📅 Upcoming games', 'upcoming'],
|
||||
];
|
||||
foreach ($gameSections as $key => [$title, $mode]): ?>
|
||||
<div class="bg-panel border border-line rounded-2xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-line flex items-center justify-between">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider"><?= $title ?></h2>
|
||||
<a href="/games" class="text-xs font-bold text-accent hover:underline">See all</a>
|
||||
</div>
|
||||
<div class="divide-y divide-line/60">
|
||||
<?php foreach (${$key} as $i => $g): ?>
|
||||
<a href="/game/<?= $g['slug'] ?>" class="flex items-center gap-3 px-4 py-2.5 hover:bg-elev/50 transition-colors group">
|
||||
<span class="w-6 text-center font-extrabold <?= $i < 3 ? 'text-accent' : 'text-muted' ?>"><?= $i + 1 ?></span>
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="" class="w-[84px] h-[39px] object-cover rounded ring-1 ring-line shrink-0" loading="lazy">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['title']) ?></div>
|
||||
<div class="text-[11px] text-muted">
|
||||
<span class="text-accent font-bold"><?= money(min($g['best_official']['price'], $g['best_keyshop']['price'])) ?></span>
|
||||
<span class="line-through ml-1"><?= money($g['base_price']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ======= news + prepaids ======= -->
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- news feed -->
|
||||
<div class="lg:col-span-2 bg-panel border border-line rounded-2xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-line flex items-center justify-between">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">📰 News</h2>
|
||||
<a href="/news" class="text-xs font-bold text-accent hover:underline">All news →</a>
|
||||
</div>
|
||||
<div class="divide-y divide-line/60">
|
||||
<?php foreach ($news as $p): ?>
|
||||
<a href="/news" class="flex items-start gap-3 px-4 py-3 hover:bg-elev/50 transition-colors group">
|
||||
<span class="kind-tag mt-0.5 <?= $p['category'] === 'freebies' ? 'kind-keyshop' : 'kind-official' ?> shrink-0"><?= esc($p['category']) ?></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-zinc-100 group-hover:text-accent leading-snug"><?= esc($p['title']) ?></div>
|
||||
<div class="text-[11px] text-muted mt-1"><?= time_ago($p['date']) ?></div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- prepaids -->
|
||||
<div class="bg-panel border border-line rounded-2xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-line flex items-center justify-between">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider">🎁 Prepaids</h2>
|
||||
<a href="/prepaids" class="text-xs font-bold text-accent hover:underline">All →</a>
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
<?php foreach ($prepaids as $g): ?>
|
||||
<a href="/prepaids" class="flex items-center justify-between px-3 py-2 rounded-lg bg-card border border-line hover:border-accent/40 transition-colors group">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-7 h-7 rounded-md flex items-center justify-center text-[10px] font-extrabold text-white shrink-0"
|
||||
style="background: linear-gradient(135deg, <?= esc($g['color'][0]) ?>, <?= esc($g['color'][1]) ?>)">
|
||||
<?= store_initials($g['name']) ?>
|
||||
</span>
|
||||
<span class="text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['name']) ?></span>
|
||||
</div>
|
||||
<span class="text-xs text-accent font-bold shrink-0">from <?= money((float) $g['offers'][0]['price']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Video game news</h1>
|
||||
<p class="text-sm text-muted mt-1">Deals, bundles, freebies, giveaways and subscriptions — all in one feed.</p>
|
||||
</div>
|
||||
|
||||
<!-- category filter -->
|
||||
<div class="flex flex-wrap gap-1.5 mb-6">
|
||||
<?php foreach ($cats as $key => $label): ?>
|
||||
<a href="<?= $key === '' ? '/news' : '/news/' . $key ?>"
|
||||
class="px-3.5 py-2 text-xs font-bold rounded-lg transition-colors border <?= $active === $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>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<?php foreach ($posts as $p): ?>
|
||||
<a href="/<?= esc($p['category']) === 'deal' ? 'deal' : (esc($p['category']) === 'freebies' ? 'freebie' : (esc($p['category']) === 'giveaways' ? 'giveaway' : 'article')) ?>/<?= esc($p['slug']) ?>"
|
||||
class="group bg-card border border-line rounded-xl p-4 hover:border-accent/40 transition-colors flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="kind-tag <?= $p['category'] === 'freebies' ? 'kind-keyshop' : 'kind-official' ?>">
|
||||
<?= esc($p['category']) ?>
|
||||
</span>
|
||||
<?php if (isset($p['date'])): ?>
|
||||
<span class="text-[11px] text-muted"><?= time_ago($p['date']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<h2 class="text-[15px] font-semibold text-zinc-100 leading-snug mb-2 group-hover:text-accent"><?= esc($p['title']) ?></h2>
|
||||
<p class="text-sm text-muted leading-relaxed line-clamp-3 flex-1"><?= esc($p['body']) ?></p>
|
||||
<span class="mt-3 pt-3 border-t border-line/60 text-xs font-bold text-accent">Read more →</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="text-xs text-muted mb-4 flex items-center gap-1.5">
|
||||
<a href="/news" class="hover:text-accent">News</a>
|
||||
<span>›</span>
|
||||
<span class="text-zinc-400"><?= ucfirst(esc($post['category'])) ?></span>
|
||||
</div>
|
||||
|
||||
<article class="max-w-3xl">
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<span class="kind-tag <?= $post['category'] === 'freebies' ? 'kind-keyshop' : 'kind-official' ?>"><?= esc($post['category']) ?></span>
|
||||
<?php if (isset($post['date'])): ?>
|
||||
<span class="text-xs text-muted"><?= time_ago($post['date']) ?> · <?= bson_date($post['date']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-white tracking-tight leading-tight mb-4"><?= esc($post['title']) ?></h1>
|
||||
|
||||
<?php if (! empty($post['game_slug'])): ?>
|
||||
<a href="/game/<?= esc($post['game_slug']) ?>" class="inline-flex items-center gap-2 bg-card border border-line rounded-lg px-3 py-2 text-xs font-bold text-accent hover:border-accent/50 transition-colors mb-6">
|
||||
View related game page →
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="prose-custom bg-panel border border-line rounded-2xl p-6 mb-8">
|
||||
<p class="text-[15px] text-zinc-200 leading-relaxed"><?= esc($post['body']) ?></p>
|
||||
|
||||
<?php if ($post['category'] === 'freebies'): ?>
|
||||
<div class="mt-6 bg-card border border-dashed border-accent/40 rounded-xl p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-bold text-accent">FREE to keep — limited time</div>
|
||||
<div class="text-xs text-muted mt-0.5">Claim it now before the offer expires</div>
|
||||
</div>
|
||||
<a href="https://www.google.com/search?q=<?= rawurlencode('claim free ' . $post['title']) ?>" target="_blank" rel="nofollow noopener"
|
||||
class="bg-accent text-accent-ink font-extrabold text-sm py-2.5 px-6 rounded-lg hover:bg-accent/90 transition-colors">
|
||||
Claim now →
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($post['category'] === 'giveaways'): ?>
|
||||
<div class="mt-6 bg-card border border-dashed border-amber-400/40 rounded-xl p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-bold text-amber-300">Giveaway in progress</div>
|
||||
<div class="text-xs text-muted mt-0.5">Enter now for a chance to win</div>
|
||||
</div>
|
||||
<button class="bg-amber-400 text-black font-extrabold text-sm py-2.5 px-6 rounded-lg hover:bg-amber-300 transition-colors">
|
||||
Enter giveaway →
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- related -->
|
||||
<?php if ($related !== []): ?>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">More <?= ucfirst(esc($post['category'])) ?> news</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-2.5">
|
||||
<?php foreach ($related as $r): ?>
|
||||
<a href="/article/<?= esc($r['slug']) ?>" class="bg-card border border-line rounded-xl p-4 hover:border-accent/40 transition-colors group">
|
||||
<div class="text-[13px] font-semibold text-zinc-100 group-hover:text-accent leading-snug"><?= esc($r['title']) ?></div>
|
||||
<div class="text-[11px] text-muted mt-1.5"><?= time_ago($r['date']) ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Compact horizontal deal card for dashboard sections.
|
||||
* $d: dashboardDeals row (best_price, best_store, best_kind, best_cut, best_hl, game, ends_within)
|
||||
* $stores: code => store map
|
||||
*/
|
||||
$g = $d['game'];
|
||||
$store = $stores[$d['best_store']] ?? null;
|
||||
$year = isset($g['release_date']) && $g['release_date'] instanceof MongoDB\BSON\UTCDateTime
|
||||
? (string) $g['release_date']->toDateTime()->format('Y')
|
||||
: '';
|
||||
?>
|
||||
<div class="flex items-center gap-3 bg-card border border-line rounded-xl p-2.5 hover:border-accent/40 transition-colors group">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="shrink-0 rounded-md overflow-hidden ring-1 ring-line">
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="<?= esc($g['title']) ?>" class="w-[104px] h-[49px] object-cover group-hover:scale-[1.04] transition-transform" loading="lazy">
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="block text-[13px] font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['title']) ?></a>
|
||||
<div class="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted">
|
||||
<?php if ($store): ?><?= view('partials/store_badge', ['store' => $store]) ?><?php endif; ?>
|
||||
<span><?= $year ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<?php if ((int) $d['best_cut'] > 0): ?>
|
||||
<div class="cut <?= cut_class((int) $d['best_cut']) ?> cut-badge mb-1">-<?= (int) $d['best_cut'] ?>%</div>
|
||||
<?php endif; ?>
|
||||
<div class="text-[15px] font-extrabold text-accent leading-none"><?= money($d['best_price']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/**
|
||||
* $row : aggregation row (_id, best_price, best_store, best_kind, best_cut, best_hl, best_expiry, game, deal_rating, ends_within)
|
||||
* $stores: code => store map
|
||||
*/
|
||||
$g = $row['game'];
|
||||
$store = $stores[$row['best_store']] ?? null;
|
||||
$hl = $g['all_time_low'];
|
||||
$hlStore = $stores[$hl['store']] ?? null;
|
||||
$rating = deal_rating((float) $hl['price'], (float) $row['best_price']);
|
||||
$year = bson_date($g['release_date']);
|
||||
$endsWithin = (int) ($row['ends_within'] ?? 0);
|
||||
$endingSoon = $endsWithin > 0 && $endsWithin < 7 * 86400000;
|
||||
$endsDays = $endingSoon ? max(1, (int) ceil($endsWithin / 86400000)) : 0;
|
||||
?>
|
||||
<div class="group bg-card border border-line rounded-xl hover:border-accent/40 transition-colors">
|
||||
<div class="flex items-stretch gap-4 p-3">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="shrink-0 rounded-lg overflow-hidden ring-1 ring-line relative">
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="<?= esc($g['title']) ?>" class="w-[132px] h-[62px] object-cover group-hover:scale-[1.03] transition-transform">
|
||||
<?php if ($endingSoon): ?>
|
||||
<span class="absolute top-0.5 right-0.5 text-[9px] font-bold text-amber-300 bg-black/70 rounded px-1 py-0.5">⏳ <?= $endsDays ?>d</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/game/<?= $g['slug'] ?>" class="block text-[15px] font-semibold text-zinc-100 hover:text-accent truncate"><?= esc($g['title']) ?></a>
|
||||
<?php if ($row['best_hl']): ?><span class="hl-flag shrink-0">★ New HL</span><?php endif; ?>
|
||||
<button @click="$store.gg.toggleWishlist('<?= $g['slug'] ?>', $event)"
|
||||
:class="$store.gg.inWishlist('<?= $g['slug'] ?>') ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||
class="shrink-0 w-6 h-6 rounded-md bg-elev/60 border border-line/60 flex items-center justify-center transition-colors"
|
||||
:title="$store.gg.inWishlist('<?= $g['slug'] ?>') ? 'Remove from wishlist' : 'Add to wishlist'">
|
||||
<svg class="w-3.5 h-3.5" :fill="$store.gg.inWishlist('<?= $g['slug'] ?>') ? 'currentColor' : '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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<span class="score-chip" title="Critic score">MC <?= (int) $g['score'] ?></span>
|
||||
<span><?= esc(substr((string) $year, -4)) ?></span>
|
||||
<span class="hidden sm:inline"><?= esc(implode(' · ', array_slice($g['genres'], 0, 2))) ?></span>
|
||||
<span class="hidden lg:inline">
|
||||
<span class="text-zinc-400"><?= (int) $g['active_deals'] ?></span> offers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right shrink-0">
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<?php if ((int) $row['best_cut'] > 0): ?>
|
||||
<span class="cut <?= cut_class((int) $row['best_cut']) ?> cut-badge">-<?= (int) $row['best_cut'] ?>%</span>
|
||||
<?php endif; ?>
|
||||
<span class="text-2xl font-extrabold text-accent leading-none"><?= money($row['best_price']) ?></span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted line-through"><?= money($g['base_price']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span class="text-muted">
|
||||
Historical low
|
||||
<span class="font-semibold text-accent"><?= money($hl['price']) ?></span>
|
||||
at <span class="text-zinc-300"><?= esc($hlStore['name'] ?? '?') ?></span>
|
||||
<span class="text-muted/70"><?= time_ago($hl['date']) ?></span>
|
||||
</span>
|
||||
<span class="rating <?= $rating['cls'] ?>"><?= $rating['label'] ?></span>
|
||||
<?php if ($row['best_hl']): ?>
|
||||
<span class="hl-flag">★ All-time low right now</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:flex flex-col justify-center gap-1.5 pl-4 border-l border-line/70 w-[172px]">
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<?= view('partials/store_badge', ['store' => $stores[$g['best_official']['store']] ?? ['name' => '?', 'color' => '#333', 'textcolor' => '#fff']]) ?>
|
||||
<span class="text-muted truncate">Official</span>
|
||||
</span>
|
||||
<span class="font-bold <?= $row['best_kind'] === 'official' ? 'text-accent' : 'text-zinc-300' ?>"><?= money($g['best_official']['price']) ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<?= view('partials/store_badge', ['store' => $stores[$g['best_keyshop']['store']] ?? ['name' => '?', 'color' => '#333', 'textcolor' => '#fff']]) ?>
|
||||
<span class="text-muted truncate">Keyshop</span>
|
||||
</span>
|
||||
<span class="font-bold <?= $row['best_kind'] === 'keyshop' ? 'text-accent' : 'text-zinc-300' ?>"><?= money($g['best_keyshop']['price']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* $store: store doc (code, name, kind, color, textcolor, logo)
|
||||
* $size : optional size modifier (sm | lg)
|
||||
*/
|
||||
$logo = $store['logo'] ?? null;
|
||||
$size = $size ?? '';
|
||||
?>
|
||||
<?php if ($logo !== null): ?>
|
||||
<span class="store-badge <?= $size ?>" style="background: <?= esc($store['color']) ?>; color: <?= esc($store['textcolor'] ?? '#fff') ?>;"
|
||||
title="<?= esc($store['name']) ?>">
|
||||
<img src="<?= esc($logo) ?>" alt="<?= esc($store['name']) ?> logo" class="store-badge-logo" loading="lazy">
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="store-badge <?= $size ?>" style="background: <?= esc($store['color']) ?>; color: <?= esc($store['textcolor'] ?? '#fff') ?>;" title="<?= esc($store['name']) ?>">
|
||||
<?= store_initials($store['name']) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Gift Cards & Prepaids</h1>
|
||||
<p class="text-sm text-muted mt-1">Buy digital gift cards for your favorite platforms at the best prices.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<?php foreach ($groups as $g): ?>
|
||||
<div class="bg-card border border-line rounded-xl overflow-hidden hover:border-accent/40 transition-colors">
|
||||
<div class="px-4 py-3 flex items-center gap-3 border-b border-line/60"
|
||||
style="background: linear-gradient(135deg, <?= esc($g['color'][0]) ?>22, <?= esc($g['color'][1]) ?>22)">
|
||||
<span class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-extrabold"
|
||||
style="background: linear-gradient(135deg, <?= esc($g['color'][0]) ?>, <?= esc($g['color'][1]) ?>); color:#fff">
|
||||
<?= store_initials($g['name']) ?>
|
||||
</span>
|
||||
<div class="font-semibold text-zinc-100 text-sm"><?= esc($g['name']) ?></div>
|
||||
</div>
|
||||
<div class="p-4 space-y-1.5">
|
||||
<?php foreach ($g['offers'] as $offer): ?>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-zinc-300 font-semibold">$<?= (int) $offer['denomination'] ?></span>
|
||||
<span class="text-muted text-xs"><?= esc($offer['store']) ?></span>
|
||||
<span class="text-accent font-bold"><?= money((float) $offer['price']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Rankings</h1>
|
||||
<p class="text-sm text-muted mt-1">Top games by wishlist, collection, critic scores and community reviews.</p>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$sections = [
|
||||
'wishlisted' => ['🔥 Most Wishlisted', $wishlisted, 'trending'],
|
||||
'collected' => ['📦 Most Collected', $collected, 'rating_pct'],
|
||||
'metacritic' => ['⭐ Metacritic', $metacritic, 'score'],
|
||||
'reviews' => ['💬 Steam Reviews', $reviews, 'rating_pct'],
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<?php foreach ($sections as $key => [$title, $list, $field]): ?>
|
||||
<div class="bg-panel border border-line rounded-2xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-line flex items-center justify-between">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider"><?= $title ?></h2>
|
||||
<span class="text-xs text-muted">Top <?= count($list) ?></span>
|
||||
</div>
|
||||
<div class="divide-y divide-line/60">
|
||||
<?php foreach ($list as $i => $g): ?>
|
||||
<a href="/game/<?= $g['slug'] ?>" class="flex items-center gap-4 px-5 py-3 hover:bg-elev/50 transition-colors group">
|
||||
<span class="w-7 text-center text-lg font-extrabold <?= $i < 3 ? 'text-accent' : 'text-muted' ?>"><?= $i + 1 ?></span>
|
||||
<img src="<?= esc($g['cover']['file']) ?>" alt="" class="w-24 h-[45px] object-cover rounded-md ring-1 ring-line shrink-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($g['title']) ?></div>
|
||||
<div class="text-xs text-muted mt-0.5">
|
||||
<span class="text-accent font-bold"><?= money($g['best_keyshop']['price'] ?? $g['base_price']) ?></span>
|
||||
<span class="line-through ml-1"><?= money($g['base_price']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-sm font-extrabold text-zinc-100">
|
||||
<?= $field === 'score' ? 'MC ' . (int) $g['score'] : (int) $g[$field] . '%' ?>
|
||||
</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">
|
||||
<?= $field === 'score' ? 'Metascore' : ($field === 'trending' ? 'Popularity' : 'Positive reviews') ?>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Stores</h1>
|
||||
<p class="text-sm text-muted mt-1">All <?= count($stores) ?> stores tracked by GG.deals — official stores and keyshop marketplaces.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<?php foreach ($stores as $s): ?>
|
||||
<a href="/?stores[]=<?= $s['code'] ?>&kind=<?= $s['kind'] ?>"
|
||||
class="group bg-card border border-line rounded-xl p-4 hover:border-accent/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<?= view('partials/store_badge', ['store' => $s]) ?>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-zinc-100 truncate group-hover:text-accent"><?= esc($s['name']) ?></div>
|
||||
<div class="kind-tag <?= $s['kind'] === 'official' ? 'kind-official' : 'kind-keyshop' ?> mt-0.5 inline-block">
|
||||
<?= $s['kind'] === 'official' ? 'Official store' : 'Keyshop' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-extrabold text-zinc-100"><?= (int) $s['deals'] ?></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">Deals</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-extrabold text-accent"><?= $s['min_price'] !== null ? money($s['min_price']) : '—' ?></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">From</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-extrabold text-zinc-100">-<?= $s['avg_cut'] ?>%</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">Avg cut</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Subscriptions</h1>
|
||||
<p class="text-sm text-muted mt-1">Game subscription services — get hundreds of games for a monthly fee.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||
<?php foreach ($subs as $s): ?>
|
||||
<div class="bg-card border border-line rounded-xl p-4 hover:border-accent/40 transition-colors group">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="w-9 h-9 rounded-lg flex items-center justify-center text-sm font-extrabold text-white shrink-0"
|
||||
style="background: linear-gradient(135deg, #155e75, #22d3ee)">
|
||||
<?= store_initials($s['name']) ?>
|
||||
</span>
|
||||
<span class="kind-tag kind-official"><?= esc($s['period']) ?></span>
|
||||
</div>
|
||||
<div class="text-[15px] font-semibold text-zinc-100 mb-1"><?= esc($s['name']) ?></div>
|
||||
<p class="text-xs text-muted leading-relaxed mb-3 min-h-[32px]"><?= esc($s['desc']) ?></p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-accent font-extrabold text-lg"><?= money((float) $s['price']) ?></span>
|
||||
<span class="text-[11px] text-muted">per month</span>
|
||||
</div>
|
||||
<?php $st = $stores[$s['store']] ?? null; ?>
|
||||
<div class="mt-2 flex items-center gap-1.5 text-[11px] text-muted">
|
||||
<?php if ($st): ?><?= view('partials/store_badge', ['store' => $st]) ?><?php endif; ?>
|
||||
<span><?= esc($st['name'] ?? '') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="text-xs text-muted mb-4 flex items-center gap-1.5">
|
||||
<a href="/faq" class="hover:text-accent">FAQ</a>
|
||||
<span>›</span>
|
||||
<span class="text-zinc-400"><?= esc($tutorial[0]) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl bg-panel border border-line rounded-2xl p-6 mb-8">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight mb-4"><?= esc($tutorial[0]) ?></h1>
|
||||
|
||||
<?php
|
||||
$content = match ($tutorial[1]) {
|
||||
'steam' => [
|
||||
'Open the Steam client on your computer and sign in.',
|
||||
'Click "Games" in the top menu, then "Activate a Product on Steam".',
|
||||
'Click "Next" and agree to the Steam Subscriber Agreement.',
|
||||
'Enter the CD key you purchased — it looks like XXXXX-XXXXX-XXXXX.',
|
||||
'Click "Next". The game will be added to your library and begin downloading automatically.',
|
||||
'You can also do this in the browser: store.steampowered.com/account/registerkey.',
|
||||
],
|
||||
'gog' => [
|
||||
'Sign in to your GOG.com account in the browser or GOG Galaxy client.',
|
||||
'In the browser, hover over your username and click "Account".',
|
||||
'Click "Redeem code" in the account menu.',
|
||||
'Enter the code from your purchase and click "Redeem".',
|
||||
'The game is added to your library. In GOG Galaxy it appears immediately — install from the library tab.',
|
||||
],
|
||||
'epic' => [
|
||||
'Open the Epic Games Launcher and sign in.',
|
||||
'Click your profile icon (top right) and select "Redeem Code".',
|
||||
'Enter the code — it is usually 5 groups of 5 characters.',
|
||||
'Click "Redeem". The game is added to your library.',
|
||||
'Find it under your library and click "Install" to download.',
|
||||
],
|
||||
'origin' => [
|
||||
'Open the EA app (formerly Origin) and sign in.',
|
||||
'Click "Menu" (≡) in the top-left, then "Redeem Product Code".',
|
||||
'Enter the code and click "Next".',
|
||||
'The game appears in your library. Select it and click "Download".',
|
||||
],
|
||||
'uplay' => [
|
||||
'Open Ubisoft Connect (Uplay) and sign in.',
|
||||
'Click "Games" then "Activate a key" in the top bar.',
|
||||
'Enter your product key.',
|
||||
'Click "Activate". The game is added to your library.',
|
||||
'Ubisoft Connect also supports claiming keys directly on account.ubisoft.com.',
|
||||
],
|
||||
'keyshops' => [
|
||||
'Keyshops are marketplaces where third-party sellers list game keys.',
|
||||
'They are usually cheaper than official stores — sometimes significantly.',
|
||||
'The main risks: region-locked keys that may not activate in your country, and keys purchased with stolen payment methods that can be revoked later.',
|
||||
'Always check the key region before buying. Global keys are the safest option.',
|
||||
'You can disable keyshop prices entirely in the site settings (Keyshops toggle).',
|
||||
'If a key fails to activate, contact the seller first — most marketplaces have buyer protection.',
|
||||
],
|
||||
};
|
||||
?>
|
||||
<ol class="space-y-3">
|
||||
<?php foreach ($content as $i => $step): ?>
|
||||
<li class="flex gap-3">
|
||||
<span class="w-6 h-6 rounded-full bg-accent/15 text-accent text-xs font-extrabold flex items-center justify-center shrink-0 mt-0.5"><?= $i + 1 ?></span>
|
||||
<span class="text-sm text-zinc-200 leading-relaxed"><?= esc($step) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="bg-panel border border-line rounded-2xl p-6 max-w-3xl">
|
||||
<h2 class="text-sm font-bold text-zinc-200 uppercase tracking-wider mb-3">Other tutorials</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-2.5">
|
||||
<?php foreach ($all as $key => [$title, $plat]) : if ($key === array_search($tutorial, $all, true)) continue; ?>
|
||||
<a href="/page/<?= $key ?>" class="flex items-center gap-3 bg-card border border-line rounded-xl px-4 py-3 hover:border-accent/40 transition-colors group">
|
||||
<span class="text-lg">📖</span>
|
||||
<span class="text-sm font-semibold text-zinc-100 group-hover:text-accent"><?= esc($title) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Promo Codes & Coupons</h1>
|
||||
<p class="text-sm text-muted mt-1">Active discount codes across all stores. Copy a code and apply it at checkout.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($vouchers === []): ?>
|
||||
<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 active vouchers for this store</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<?php foreach ($vouchers as $v):
|
||||
$store = $stores[$v['store']] ?? null;
|
||||
$expires = $v['expires'] ?? null;
|
||||
$daysLeft = $expires instanceof MongoDB\BSON\UTCDateTime
|
||||
? max(1, (int) floor(($expires->toDateTime()->getTimestamp() - time()) / 86400))
|
||||
: null;
|
||||
?>
|
||||
<div class="bg-card border border-line rounded-xl p-4 hover:border-accent/40 transition-colors group">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<?php if ($store): ?><?= view('partials/store_badge', ['store' => $store, 'size' => 'lg']) ?><?php endif; ?>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-zinc-100 truncate"><?= esc($v['title']) ?></div>
|
||||
<div class="text-xs text-muted"><?= esc($store['name'] ?? $v['store']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ copied: false }" class="flex items-center gap-2">
|
||||
<button @click="navigator.clipboard.writeText('<?= esc($v['code']) ?>').then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
|
||||
class="flex-1 bg-page border border-dashed border-accent/50 rounded-lg py-2.5 text-center font-mono font-bold text-accent hover:bg-accent/10 transition-colors"
|
||||
title="Click to copy">
|
||||
<?= esc($v['code']) ?>
|
||||
</button>
|
||||
<button @click="navigator.clipboard.writeText('<?= esc($v['code']) ?>').then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
|
||||
class="px-3 py-2.5 rounded-lg bg-accent text-accent-ink text-xs font-bold hover:bg-accent/90 transition-colors">
|
||||
<span x-show="!copied">Copy</span>
|
||||
<span x-show="copied" x-cloak>✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between text-xs text-muted">
|
||||
<span class="cut <?= cut_class((int) $v['discount']) ?> cut-badge">-<?= (int) $v['discount'] ?>%</span>
|
||||
<?php if ($v['min_spend'] > 0): ?><span>min. <?= money((float) $v['min_spend']) ?></span><?php endif; ?>
|
||||
<?php if ($daysLeft !== null): ?><span class="text-amber-400/90">expires in <?= $daysLeft ?>d</span><?php endif; ?>
|
||||
</div>
|
||||
<?php if (! empty($v['description'])): ?>
|
||||
<p class="mt-2 text-xs text-muted/80 leading-relaxed"><?= esc($v['description']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
|
||||
<?= $this->extend('layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold text-white tracking-tight">Your wishlist</h1>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
<span x-text="$store.gg.wishlist.length"></span> game<span x-show="$store.gg.wishlist.length !== 1">s</span> saved · tracked in your browser
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-data="wishlistPage()" x-init="load()" class="mb-6">
|
||||
<div x-show="!loaded" class="text-center text-muted py-10 text-sm">Loading…</div>
|
||||
|
||||
<div x-show="loaded && games.length === 0" x-cloak 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">Your wishlist is empty</div>
|
||||
<p class="text-muted text-sm mt-1">Browse the deals and click the heart on any game to save it here.</p>
|
||||
<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">Browse deals</a>
|
||||
</div>
|
||||
|
||||
<div x-show="games.length" x-cloak class="space-y-2.5">
|
||||
<template x-for="g in games" :key="g.slug">
|
||||
<div class="bg-card border border-line rounded-xl p-3 flex items-center gap-4 hover:border-accent/40 transition-colors">
|
||||
<a :href="'/game/' + g.slug" class="shrink-0 rounded-lg overflow-hidden ring-1 ring-line">
|
||||
<img :src="g.cover.file" class="w-[132px] h-[62px] object-cover" alt="">
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a :href="'/game/' + g.slug" class="text-[15px] font-semibold text-zinc-100 hover:text-accent truncate block" x-text="g.title"></a>
|
||||
<div class="mt-1 text-xs text-muted flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="score-chip" x-text="'MC ' + g.score"></span>
|
||||
<span x-text="(g.genres || []).slice(0, 2).join(' · ')"></span>
|
||||
<span>
|
||||
<span class="text-zinc-400" x-text="g.active_deals"></span> offers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<span class="text-2xl font-extrabold text-accent leading-none" x-text="'$' + Number(g.best_keyshop.price).toFixed(2)"></span>
|
||||
</div>
|
||||
<div class="text-xs text-muted line-through mt-1" x-text="'$' + Number(g.base_price).toFixed(2)"></div>
|
||||
</div>
|
||||
<button @click="$store.gg.toggleWishlist(g.slug)"
|
||||
class="shrink-0 w-9 h-9 rounded-lg bg-elev border border-line flex items-center justify-center text-accent hover:border-accent/50 transition-colors"
|
||||
title="Remove from wishlist">
|
||||
<svg class="w-4 h-4" fill="currentColor" 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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
function wishlistPage() {
|
||||
return {
|
||||
games: [],
|
||||
loaded: false,
|
||||
async load() {
|
||||
const slugs = Alpine.store('gg').wishlist;
|
||||
if (slugs.length === 0) { this.loaded = true; return; }
|
||||
const res = await fetch('/api/wishlist?slugs[]=' + slugs.map(encodeURIComponent).join('&slugs[]='));
|
||||
this.games = await res.json();
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
Reference in New Issue
Block a user