- 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
333 lines
18 KiB
PHP
333 lines
18 KiB
PHP
<?= $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() ?>
|