Files
ggdeals/app/Views/game.php
T
deepseek fff7a4b9b9 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
2026-08-23 07:06:02 +00:00

602 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?= $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 &amp; 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 &amp; 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() ?>