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,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() ?>
|
||||
Reference in New Issue
Block a user