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