- 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
111 lines
3.3 KiB
PHP
111 lines
3.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Price formatter.
|
|
*/
|
|
function money(float|int|null $v): string
|
|
{
|
|
if ($v === null) {
|
|
return '—';
|
|
}
|
|
|
|
return '$' . number_format((float) $v, 2);
|
|
}
|
|
|
|
/**
|
|
* Discount badge class: bigger cut => hotter color.
|
|
*/
|
|
function cut_class(int $cut): string
|
|
{
|
|
return $cut >= 75 ? 'cut-hi' : ($cut >= 50 ? 'cut-mid' : 'cut-lo');
|
|
}
|
|
|
|
/**
|
|
* Deal rating label comparing current price to the all-time low.
|
|
*/
|
|
function deal_rating(float $hl, float $price): array
|
|
{
|
|
$ratio = $price > 0 ? $hl / $price : 0;
|
|
|
|
return match (true) {
|
|
$ratio >= 0.99 => ['label' => '🔥 Hot', 'cls' => 'rating-best', 'ratio' => $ratio],
|
|
$ratio >= 0.85 => ['label' => '🔥 Hot', 'cls' => 'rating-great', 'ratio' => $ratio],
|
|
$ratio >= 0.6 => ['label' => 'Good deal', 'cls' => 'rating-good', 'ratio' => $ratio],
|
|
$ratio >= 0.35 => ['label' => 'Fair deal', 'cls' => 'rating-fair', 'ratio' => $ratio],
|
|
default => ['label' => 'High price', 'cls' => 'rating-high', 'ratio' => $ratio],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a URL like the current one with query params overridden.
|
|
*/
|
|
function qs_url(array $overrides = [], array $remove = []): string
|
|
{
|
|
$get = service('request')->getGet();
|
|
$get = array_map(static fn ($v) => is_array($v) ? array_values(array_filter($v, 'strlen')) : $v, $get);
|
|
|
|
foreach ($remove as $key) {
|
|
unset($get[$key]);
|
|
}
|
|
|
|
$get = array_merge($get, $overrides);
|
|
// drop empty / zero-default params for clean URLs
|
|
foreach ($get as $key => $val) {
|
|
if (is_array($val)) {
|
|
if ($val === []) unset($get[$key]);
|
|
continue;
|
|
}
|
|
if ($key === 'min_cut' && (int) $val === 0) { unset($get[$key]); continue; }
|
|
if ($key === 'kind' && $val === '') { unset($get[$key]); continue; }
|
|
if ($key === 'page' && ($val === null || (int) $val === 1)) { unset($get[$key]); continue; }
|
|
if (($key === 'min_price' || $key === 'max_price' || $key === 'q') && $val === '') { unset($get[$key]); continue; }
|
|
}
|
|
|
|
$qs = http_build_query($get);
|
|
|
|
// keep the current path (e.g. /games/rpg) so filters stay within it
|
|
$path = '/' . ltrim(uri_string(), '/');
|
|
|
|
return $path . ($qs !== '' ? '?' . $qs : '');
|
|
}
|
|
|
|
function store_initials(string $name): string
|
|
{
|
|
$words = preg_split('/\s+/', $name) ?: [];
|
|
$ini = '';
|
|
foreach ($words as $w) {
|
|
if ($w === '' || ! ctype_alpha($w[0])) {
|
|
continue;
|
|
}
|
|
$ini .= strtoupper($w[0]);
|
|
if (strlen($ini) >= 2) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $ini !== '' ? $ini : strtoupper(substr($name, 0, 2));
|
|
}
|
|
|
|
function time_ago(int|string|MongoDB\BSON\UTCDateTime $ts): string
|
|
{
|
|
if ($ts instanceof MongoDB\BSON\UTCDateTime) {
|
|
$ts = $ts->toDateTime()->getTimestamp();
|
|
}
|
|
$diff = max(1, time() - (int) $ts);
|
|
if ($diff < 3600) return floor($diff / 60) . ' min ago';
|
|
if ($diff < 86400) return floor($diff / 3600) . ' h ago';
|
|
if ($diff < 86400*30) return floor($diff / 86400) . ' days ago';
|
|
if ($diff < 86400*365) return floor($diff / (86400*30)) . ' months ago';
|
|
return floor($diff / (86400*365)) . ' y ago';
|
|
}
|
|
|
|
function bson_date($ts): ?string
|
|
{
|
|
if (! $ts instanceof MongoDB\BSON\UTCDateTime) {
|
|
return null;
|
|
}
|
|
return date('M j, Y', $ts->toDateTime()->getTimestamp());
|
|
}
|