commit fff7a4b9b9d09b0087cbb51b5cf117d3627cddc5 Author: deepseek Date: Sun Aug 23 07:06:02 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f14654b --- /dev/null +++ b/.gitignore @@ -0,0 +1,138 @@ +#------------------------- +# Operating Specific Junk Files +#------------------------- + +# OS X +.DS_Store +.AppleDouble +.LSOverride + +# OS X Thumbnails +._* + +# Windows image file caches +Thumbs.db +ehthumbs.db +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Linux +*~ + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +#------------------------- +# Environment Files +#------------------------- +# These should never be under version control, +# as it poses a security risk. +.env +.vagrant +Vagrantfile + +#------------------------- +# Temporary Files +#------------------------- +/writable/cache/* +!/writable/cache/index.html + +/writable/logs/* +!/writable/logs/index.html + +/writable/session/* +!/writable/session/index.html + +/writable/uploads/* +!/writable/uploads/index.html + +/writable/debugbar/* +!/writable/debugbar/index.html + +php_errors.log + +#------------------------- +# User Guide Temp Files +#------------------------- +/user_guide_src/build/* +/user_guide_src/cilexer/build/* +/user_guide_src/cilexer/dist/* +/user_guide_src/cilexer/pycilexer.egg-info/* + +#------------------------- +# Test Files +#------------------------- +/tests/coverage* + +# Don't save phpunit under version control. +/phpunit + +#------------------------- +# Composer +#------------------------- +vendor/ + +#------------------------- +# IDE / Development Files +#------------------------- + +# Modules Testing +_modules/* + +# phpenv local config +.php-version + +# Jetbrains editors (PHPStorm, etc) +.idea/ +*.iml + +# NetBeans +/nbproject/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/nbactions.xml +/nb-configuration.xml +/.nb-gradle/ + +# Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project +.phpintel +/api/ + +# Visual Studio Code +.vscode/ + +/results/ +/phpunit.xml + +# ---- Node / frontend build ---- +node_modules/ +package-lock.json +public/js/chart.umd.js +public/js/alpine.min.js +public/css/app.css +assets/node_modules/ + +# ---- Crawled assets (regenerable) ---- +public/img/covers/ +public/img/stores/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24728f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 British Columbia Institute of Technology +Copyright (c) 2019-present CodeIgniter Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c2e84 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# GG.deals Clone + +A demo clone of [gg.deals](https://gg.deals/) — PC game price comparison — built with: + +- **CodeIgniter 4** (PHP 8.2) — MVC backend +- **MongoDB** — data store (`harness-mongo`, db: `ggdeals`) +- **Tailwind CSS v4** — styling (dark + light theme) +- **Alpine.js** — interactivity (tabs, filters, wishlist store, settings store, search dropdown) +- **Chart.js** — price history charts + +The feature set was modeled after a **deep crawl of gg.deals itself**: the full sitemap tree (12 sitemap types, 5k+ URLs) plus Wayback Machine snapshots of every page type (homepage, deals, game, bundle, freebie, giveaway, franchise, game-points, API, rankings, vouchers, news). + +## Features + +### Homepage dashboard (gg.deals-style, 10 sections) +- Most Popular Games, New deals, Best deals, Historical lows, Ending Soon +- 🔥 Most Wanted, 🆕 New releases, 📅 Upcoming games +- 📰 News feed, 🎁 Prepaids +- Stats strip (games / deals / stores / all-time lows now) + +### Deal feed & presets (`/deals/{preset}`) +- Preset tabs: Hot New Deals, New Deals, Historical Lows, Ending Soon, ≥75% off, ≥85% off +- Advanced filters: price tiers (Under $2–$30), discount tiers (≥50–90%), deal rating (🔥 Hot/Good/Fair), release year ranges, only all-time lows, only-from-wishlist, per-store checkboxes with real logos +- Sorts: Best deals, Deal rating, Biggest discount, Cheapest ever, New releases, Lowest price, Metascore, Title A–Z, Oldest release, Ending soon +- Deal rows with 🔥 Hot / Good / Fair badges, ★ New HL flags, ⏳ ending-soon timers, wishlist hearts + +### Game page (`/game/{slug}`) +- **Header price tabs**: Current Prices / Historical Low +- Price summary cards: Official Stores, Keyshops, Subscriptions, All-time low +- Deals tables (official stores & keyshops) with DRM, region locks, expiry, HL flags +- Chart.js price history (3M/6M/1Y/All) vs all-time low +- **Bundles with this game**, **Series & franchises**, **Similar games** sections +- About (dev/publisher/age rating), System Requirements, How Long to Beat + +### Content & community pages +- **Bundle pages** (`/bundle/{slug}`) — games list, price, savings, store +- **News articles** (`/article|deal|freebie|giveaway/{slug}`) — categorized posts with claim/enter CTAs +- **Franchise pages** (`/franchise/{slug}`) — all games in a series +- **Rankings** — Most Wishlisted, Most Collected, Metacritic, Steam Reviews +- **Vouchers** — promo codes per store with copy-to-clipboard +- **Subscriptions** — Game Pass, PS Plus, EA Play, Ubisoft+ cards +- **Prepaids** — gift card groups with denominations +- **Games browser** (`/games/{genre}`) — same deal-feed layout (filter sidebar + row list + pagination) with genre chips on top, **Stores**, **Wishlist** (localStorage) + +### Site features +- **Region & currency selector** (19 regions, 7 currencies, localStorage-persisted) +- **Keyshops toggle** (on/off) +- **Dark / Light theme toggle** +- **API page** + **Game Prices API** (`/api/prices?appid=`) with live demo tester +- **FAQ** (accordion) + **CD key activation tutorials** (Steam/GOG/Epic/Origin/Uplay/Keyshops) +- Live search suggestions + +### Assets +- **Real cover art** — crawled from Steam CDN (`php spark gg:crawl-covers`) +- **Real store logos** — brand favicons in `public/img/stores/` + +## Quick start + +```bash +# 1. Configure .env (MONGO_URI / MONGO_DB) +cp env .env + +# 2. Install PHP + frontend deps +composer install +npm install + +# 3. Build CSS +npx @tailwindcss/cli -i assets/css/app.css -o public/css/app.css --minify + +# 4. Seed the database (stores, 61 games, 549 deals, ~26k price points, vouchers, news, prepaids, subscriptions, bundles, franchises, covers) +php spark gg:seed + +# 5. Crawl real cover art from Steam +php spark gg:crawl-covers + +# 6. Serve +php spark serve --host 0.0.0.0 --port 8085 +``` + +## Data model (MongoDB collections) + +- `stores` — code, name, kind, colors, logo +- `games` — slug, title, base price, genres, platforms, scores, all-time low, best prices, developer/publisher, sys reqs, HLTB, age rating, description +- `deals` — per-store offers with cut %, HL flag, DRM, expiry +- `price_history` — 5-day cadence price points per store +- `vouchers` — promo codes per store +- `news` — categorized posts (deals/bundles/freebies/giveaways/subscriptions) +- `prepaids` — gift card groups +- `subscriptions` — game subscription services +- `bundles` — bundles with included game slugs +- `franchises` — franchise series + +## Layout + +``` +app/ + Commands/ SeedDatabase (gg:seed), CrawlCovers (gg:crawl-covers) + Controllers/ Home, Game, Games, Rankings, Vouchers, News, Prepaids, + Subscriptions, Bundle, Franchise, Wishlist, Stores, Pages, Api + Helpers/gg_helper.php money(), cut_class(), deal_rating(), qs_url(), time_ago() + Libraries/Mongo.php MongoDB client wrapper + Models/GameModel.php aggregation pipelines for feed/chart/dashboard/similar + Views/ layout, homepage, home, game, + 15 more page views, partials/ +assets/css/app.css Tailwind v4 source (theme tokens + dark/light) +``` + +> Demo data only: prices are randomly generated, buy links point to search results. diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/app/Commands/CrawlCatalog.php b/app/Commands/CrawlCatalog.php new file mode 100644 index 0000000..fb5389f --- /dev/null +++ b/app/Commands/CrawlCatalog.php @@ -0,0 +1,126 @@ + $v) { + if (is_string($k) && str_starts_with($k, 'limit=')) { + $limit = (int) substr($k, 6); + } + } + + // resolve the sitemap index + $index = $this->fetch('https://gg.deals/sitemap.xml'); + if ($index === null) { + CLI::error('Could not fetch sitemap index'); + + return; + } + + preg_match_all('/sitemap_games_(\d+)\.xml/', $index, $m); + $files = array_map(static fn ($n) => (int) $n, $m[1]); + sort($files, SORT_NUMERIC); + + CLI::write('Found ' . count($files) . ' game sitemap files.', 'yellow'); + if ($limit !== null) { + $files = array_slice($files, 0, $limit); + CLI::write('Limit set: first ' . count($files) . ' files.', 'yellow'); + } + + $totalUrls = 0; + $totalInserted = 0; + $totalSkipped = 0; + + foreach ($files as $i => $n) { + $url = "https://gg.deals/sitemap_games_{$n}.xml"; + $xml = $this->fetch($url); + if ($xml === null) { + CLI::error(" sitemap {$n}: fetch failed"); + usleep(500000); + continue; + } + + preg_match_all('/(https:\/\/gg\.deals\/game\/([a-z0-9-]+)\/?)<\/loc>/', $xml, $matches); + $slugs = array_values(array_unique($matches[2])); + $totalUrls += count($slugs); + + $docs = []; + foreach ($slugs as $slug) { + $docs[] = [ + 'slug' => $slug, + 'url' => 'https://gg.deals/game/' . $slug . '/', + 'crawled' => false, + 'added_at' => new UTCDateTime(time() * 1000), + ]; + } + + if ($docs !== []) { + try { + Mongo::collection('catalog')->insertMany($docs, ['ordered' => false]); + $totalInserted += count($docs); + } catch (\MongoDB\Driver\Exception\BulkWriteException $e) { + // duplicate keys are expected on re-runs + $totalInserted += count($docs); + } + } + + CLI::write(" [{$i}/" . count($files) . "] sitemap {$n}: " . count($slugs) . ' slugs (total ' . number_format($totalInserted) . ')'); + usleep(300000); + } + + CLI::write(''); + CLI::write("Catalog crawl done: {$totalUrls} urls, {$totalInserted} inserted.", 'green'); + CLI::write('Total catalog documents: ' . Mongo::collection('catalog')->countDocuments(), 'green'); + } + + private function fetch(string $url): ?string + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => ['Accept: application/xml,text/xml,*/*'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false || $body === '') { + return null; + } + + return $body; + } +} diff --git a/app/Commands/CrawlCovers.php b/app/Commands/CrawlCovers.php new file mode 100644 index 0000000..31f1517 --- /dev/null +++ b/app/Commands/CrawlCovers.php @@ -0,0 +1,184 @@ + steam appid */ + public const APP_IDS = [ + 'age-of-empires-iv' => 1466860, + 'alan-wake-2' => 2379780, + 'balan-wonderworld' => 1336480, + 'baldurs-gate-3' => 1086940, + 'cities-skylines-ii' => 949230, + 'control' => 870780, + 'counter-strike-2' => 730, + 'cuphead' => 268910, + 'cyberpunk-2077' => 1091500, + 'dark-souls-iii' => 374320, + 'dead-cells' => 588650, + 'death-stranding' => 1190460, + 'deep-rock-galactic' => 548430, + 'diablo-iv' => 2344520, + 'disco-elysium' => 632470, + 'dishonored-2' => 403640, + 'divinity-original-sin-2' => 435150, + 'doom-eternal' => 782330, + 'ea-sports-fc-24' => 2195250, + 'efootball-2024' => 2349380, + 'elden-ring' => 1245620, + 'enshrouded' => 1203620, + 'factorio' => 427520, + 'forza-horizon-5' => 1551360, + 'god-of-war' => 1593500, + 'grand-theft-auto-v' => 271590, + 'hades-ii' => 1145350, + 'helldivers-2' => 553850, + 'hogwarts-legacy' => 990080, + 'hollow-knight' => 367520, + 'horizon-forbidden-west' => 2420110, + 'it-takes-two' => 1426210, + 'kingdom-come-deliverance' => 379430, + 'lethal-company' => 1966720, + 'manor-lords' => 1363080, + 'marvels-spider-man-2' => 2651290, + 'marvels-spider-man-remastered' => 1817070, + 'metro-exodus' => 412020, + 'microsoft-flight-simulator' => 1250410, + 'monster-hunter-rise' => 1446780, + 'nier-automata' => 524220, + 'ori-and-the-will-of-the-wisps' => 1057090, + 'outer-wilds' => 753640, + 'palworld' => 1623730, + 'persona-5-royal' => 1687950, + 'ready-or-not' => 1144200, + 'red-dead-redemption-2' => 1174180, + 'resident-evil-4' => 2050650, + 'rimworld' => 294100, + 'sekiro-shadows-die-twice' => 814380, + 'slay-the-spire' => 646570, + 'sonic-frontiers' => 1237320, + 'stardew-valley' => 413150, + 'starfield' => 1716740, + 'street-fighter-6' => 1364780, + 'terraria' => 105600, + 'the-last-of-us-part-i' => 1888930, + 'the-witcher-3-wild-hunt' => 292030, + 'total-war-warhammer-iii' => 1142710, + 'valheim' => 892970, + 'warhammer-40000-darktide' => 1361210, + ]; + + private const CDN = 'https://cdn.cloudflare.steamstatic.com/steam/apps/%d/'; + private const OUT_DIR = WRITEPATH . '../public/img/covers/'; + + private const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'; + + public function run(array $params): void + { + $games = Mongo::collection('games')->find()->toArray(); + + if (! is_dir(self::OUT_DIR)) { + mkdir(self::OUT_DIR, 0777, true); + } + + $ok = 0; + $fail = []; + $skipped = 0; + + foreach ($games as $game) { + $slug = $game['slug']; + $appid = self::APP_IDS[$slug] ?? null; + + if ($appid === null) { + $skipped++; + continue; + } + + $filename = $slug . '.jpg'; + $dest = self::OUT_DIR . $filename; + + $downloaded = $this->download( + sprintf(self::CDN, $appid) . 'header.jpg', + $dest + ); + + if (! $downloaded) { + // try the capsule variant as a fallback + $downloaded = $this->download( + sprintf(self::CDN, $appid) . 'capsule_616x353.jpg', + $dest + ); + } + + if ($downloaded) { + Mongo::collection('games')->updateOne( + ['slug' => $slug], + ['$set' => ['cover' => ['file' => '/img/covers/' . $filename, 'from' => '#000000', 'to' => '#000000']]] + ); + $ok++; + CLI::write(' ✓ ' . $slug . ' (' . $appid . ')', 'green'); + } else { + $fail[] = $slug; + CLI::write(' ✗ ' . $slug . ' (' . $appid . ')', 'red'); + } + + usleep(120000); // be gentle with the CDN + } + + CLI::write(''); + CLI::write(sprintf('Downloaded %d covers, skipped %d (no appid), failed %d.', $ok, $skipped, count($fail)), 'cyan'); + if ($fail !== []) { + CLI::write('Failed: ' . implode(', ', $fail), 'yellow'); + } + } + + private function download(string $url, string $dest): bool + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if ($code !== 200 || $body === false || strlen($body) < 1000) { + return false; + } + + // ensure it's an image + if (! str_starts_with($type, 'image/')) { + return false; + } + + return file_put_contents($dest, $body) !== false; + } +} diff --git a/app/Commands/CrawlGames.php b/app/Commands/CrawlGames.php new file mode 100644 index 0000000..df05208 --- /dev/null +++ b/app/Commands/CrawlGames.php @@ -0,0 +1,661 @@ +crawlSteam(); + } + if ($target === 'all' || $target === 'gog') { + $this->crawlGog(); + } + if ($target === 'all' || $target === 'epic') { + $this->crawlEpic(); + } + + if ($target === 'all' || $target === 'finalize' || in_array($target, ['steam', 'gog', 'epic'], true)) { + $this->finalizeGames(); + $this->recordPriceHistory(); + } + + CLI::write('Crawler finished — ' . date('Y-m-d H:i:s'), 'green'); + } + + /** + * Records today's cheapest price per game/store-kind into price_history, + * so the charts grow real data over time. + */ + private function recordPriceHistory(): void + { + CLI::write('-- Recording price history --', 'cyan'); + + $todayStart = (new \DateTime('today'))->getTimestamp(); + $now = time(); + $inserted = 0; + + $deals = Mongo::collection('deals')->find()->toArray(); + $byGame = []; + + foreach ($deals as $d) { + $byGame[$d['game_slug']][$d['kind']][] = (float) $d['price']; + } + + foreach ($byGame as $slug => $kinds) { + foreach (['official', 'keyshop'] as $kind) { + if (empty($kinds[$kind])) { + continue; + } + $min = min($kinds[$kind]); + + // only insert one point per game+kind+day + $existing = Mongo::collection('price_history')->findOne([ + 'game_slug' => $slug, + 'kind' => $kind, + 'ts' => ['$gte' => new UTCDateTime($todayStart * 1000)], + ]); + if ($existing !== null) { + continue; + } + + Mongo::collection('price_history')->insertOne([ + 'game_slug' => $slug, + 'store_code' => 'aggregate', + 'kind' => $kind, + 'ts' => new UTCDateTime($now * 1000), + 'price' => round($min, 2), + ]); + $inserted++; + } + } + + CLI::write("Recorded {$inserted} price history points.", 'green'); + } + + /** + * Recomputes per-game aggregate fields (best prices, active deal counts, + * all-time low) from the deals collection — keeps views working even when + * a game has no official/keyshop store deals. + */ + private function finalizeGames(): void + { + CLI::write('-- Finalizing game aggregates --', 'cyan'); + + $deals = Mongo::collection('deals')->find()->toArray(); + $byGame = []; + + foreach ($deals as $d) { + $slug = $d['game_slug']; + $byGame[$slug][] = $d; + } + + $updated = 0; + $now = time(); + + foreach ($byGame as $slug => $gameDeals) { + $official = array_values(array_filter($gameDeals, static fn ($d) => $d['kind'] === 'official')); + $keyshop = array_values(array_filter($gameDeals, static fn ($d) => $d['kind'] === 'keyshop')); + + usort($official, static fn ($a, $b) => $a['price'] <=> $b['price']); + usort($keyshop, static fn ($a, $b) => $a['price'] <=> $b['price']); + usort($gameDeals, static fn ($a, $b) => $a['price'] <=> $b['price']); + + $bestOfficial = $official[0] ?? null; + $bestKeyshop = $keyshop[0] ?? null; + $cheapest = $gameDeals[0] ?? null; + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + if ($existing === null) { + continue; + } + + // all-time low: keep the historical minimum, seed from current cheapest if none + $hl = $existing['all_time_low'] ?? null; + if ($cheapest !== null && ($hl === null || (float) $cheapest['price'] < (float) $hl['price'])) { + $hl = [ + 'price' => (float) $cheapest['price'], + 'store' => $cheapest['store_code'], + 'kind' => $cheapest['kind'], + 'date' => new UTCDateTime($now * 1000), + ]; + } + + $set = [ + 'best_official' => [ + 'price' => $bestOfficial ? (float) $bestOfficial['price'] : 0.0, + 'store' => $bestOfficial ? $bestOfficial['store_code'] : '', + 'cut' => $bestOfficial ? (int) $bestOfficial['cut'] : 0, + ], + 'best_keyshop' => [ + 'price' => $bestKeyshop ? (float) $bestKeyshop['price'] : 0.0, + 'store' => $bestKeyshop ? $bestKeyshop['store_code'] : '', + 'cut' => $bestKeyshop ? (int) $bestKeyshop['cut'] : 0, + ], + 'active_deals' => count($gameDeals), + 'all_time_low' => $hl, + 'trending' => $existing['trending'] ?? mt_rand(30, 90), + ]; + + Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $set]); + $updated++; + } + + CLI::write("Finalized {$updated} games with aggregates.", 'green'); + } + + // ------------------------------------------------------------------ Steam + + private function crawlSteam(): void + { + CLI::write('-- Steam --', 'cyan'); + + $data = $this->fetchJson(self::STEAM_FEATURED); + if ($data === null) { + CLI::error('Steam featured fetch failed'); + + return; + } + + $appIds = []; + $featured = []; + + foreach (['specials', 'top_sellers', 'new_releases', 'coming_soon'] as $cat) { + foreach ($data[$cat]['items'] ?? [] as $item) { + $appIds[$item['id']] = true; + $featured[$item['id']] = [ + 'category' => $cat, + 'discount_percent' => (int) ($item['discount_percent'] ?? 0), + 'final_price' => (int) ($item['final_price'] ?? 0), + 'original_price' => (int) ($item['original_price'] ?? 0), + ]; + } + } + + // also add our existing appids so we refresh their details + foreach (\App\Commands\CrawlCovers::APP_IDS as $slug => $appid) { + $appIds[$appid] = true; + } + + // crawl Steam search catalog (games only) — several pages of 50 + $searchPages = (int) env('GG_CRAWL_STEAM_PAGES', '4'); + foreach ($this->steamSearchAppIds($searchPages) as $appid) { + $appIds[$appid] = true; + } + + CLI::write('Found ' . count($appIds) . ' Steam apps to fetch details for.', 'yellow'); + + $gamesInserted = 0; + $gamesUpdated = 0; + $dealsInserted = 0; + $dealsUpdated = 0; + + foreach (array_keys($appIds) as $i => $appid) { + $details = $this->fetchJson(sprintf(self::STEAM_APP_DETAILS, (int) $appid)); + if ($details === null || empty($details[(string) $appid]['success'])) { + continue; + } + $g = $details[(string) $appid]['data'] ?? null; + if ($g === null || empty($g['name'])) { + continue; + } + + $slug = url_title($g['name'], '-', true); + if ($slug === '') { + continue; + } + + $priceOverview = $g['price_overview'] ?? []; + $priceUsd = isset($priceOverview['final']) ? (float) $priceOverview['final'] / 100 : null; + $baseUsd = isset($priceOverview['initial']) ? (float) $priceOverview['initial'] / 100 : null; + $cut = (int) ($priceOverview['discount_percent'] ?? 0); + + $genres = array_map(static fn ($x) => $x['description'], $g['genres'] ?? []); + $platforms = ['steam']; + foreach ($g['platforms'] ?? [] as $plat => $supported) { + if ($supported) { + $platforms[] = $plat; + } + } + + $now = time(); + $cover = $this->steamCoverUrl((int) $appid); + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + $oldBase = $existing ? (float) $existing['base_price'] : ($baseUsd ?? 0); + + $doc = [ + 'slug' => $slug, + 'title' => $g['name'], + 'steam_appid' => (int) $appid, + 'base_price' => $baseUsd ?? $oldBase, + 'genres' => array_values(array_unique(array_filter($genres))), + 'platforms' => array_values(array_unique($platforms)), + 'score' => (int) ($g['metacritic']['score'] ?? 0), + 'rating_pct' => (int) ($g['metacritic']['score'] ?? 0), + 'release_date' => $this->parseDate($g['release_date']['date'] ?? null), + 'developer' => ($g['developers'][0] ?? null) ?: 'Unknown', + 'publisher' => ($g['publishers'][0] ?? null) ?: 'Unknown', + 'cover' => ['file' => $cover, 'from' => '#1b2838', 'to' => '#171d25'], + 'description' => $g['short_description'] ?? '', + 'age_rating' => 'Steam', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'steam', + ]; + + // preserve existing all_time_low / best prices if present + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $result = Mongo::collection('games')->updateOne( + ['slug' => $slug], + ['$set' => $doc], + ['upsert' => true] + ); + if ($result->getUpsertedCount() > 0) { + $gamesInserted++; + } else { + $gamesUpdated++; + } + + // ---- deal (official store = Steam) + if ($priceUsd !== null && $baseUsd !== null && $baseUsd > 0) { + $hlPrice = $existing['all_time_low']['price'] ?? null; + $dealDoc = [ + 'game_slug' => $slug, + 'title' => $g['name'], + 'store_code' => 'steam', + 'kind' => 'official', + 'price' => round($priceUsd, 2), + 'regular' => round($baseUsd, 2), + 'cut' => max(0, min(95, $cut)), + 'historical_low' => $hlPrice !== null && $priceUsd <= $hlPrice + 0.01, + 'drm' => 'Steam key', + 'regional' => false, + 'expiry' => null, + 'url' => 'https://store.steampowered.com/app/' . (int) $appid . '/', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'steam', + ]; + $dResult = Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'steam'], + ['$set' => $dealDoc], + ['upsert' => true] + ); + if ($dResult->getUpsertedCount() > 0) { + $dealsInserted++; + } else { + $dealsUpdated++; + } + } + + if ($i % 25 === 0) { + CLI::write(" ... {$i}/" . count($appIds)); + } + usleep(200000); // rate limit ~5 req/s + } + + CLI::write("Steam done: {$gamesInserted} games inserted, {$gamesUpdated} updated, {$dealsInserted} deals inserted, {$dealsUpdated} deals updated.", 'green'); + } + + private function steamCoverUrl(int $appid): string + { + return "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{$appid}/header.jpg"; + } + + /** + * Collects app IDs from the Steam store search pages (category1=998 → games). + */ + private function steamSearchAppIds(int $pages): array + { + $ids = []; + for ($page = 0; $page < $pages; $page++) { + $url = sprintf(self::STEAM_SEARCH, $page * 100); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false) { + continue; + } + + // results_html is JSON-escaped: data-ds-appid=\"123456\" + if (preg_match_all('/data-ds-appid=\\\\"(\d{3,9})\\\\"/', $body, $m) + || preg_match_all('/data-ds-appid="(\d{3,9})"/', $body, $m)) { + foreach ($m[1] as $appid) { + $ids[(int) $appid] = true; + } + } + + CLI::write(" search page {$page}: " . count($ids) . ' unique ids so far'); + usleep(300000); + } + + return array_keys($ids); + } + + // ------------------------------------------------------------------ GOG + + private function crawlGog(): void + { + CLI::write('-- GOG --', 'cyan'); + + $limit = 48; + $pages = (int) env('GG_CRAWL_GOG_PAGES', '6'); + $inserted = 0; + $updated = 0; + $dealsInserted = 0; + $now = time(); + + for ($page = 0; $page < $pages; $page++) { + $url = sprintf(self::GOG_CATALOG, $limit) . '&page=' . $page . '&pageSize=' . $limit; + $data = $this->fetchJson($url); + if ($data === null || empty($data['products'])) { + CLI::write(" GOG page {$page}: no products, stopping."); + break; + } + CLI::write(" GOG page {$page}: " . count($data['products']) . ' products'); + + foreach ($data['products'] as $p) { + $title = $p['title'] ?? null; + if ($title === null) { + continue; + } + $slug = url_title($title, '-', true); + if ($slug === '') { + continue; + } + + $price = $p['price'] ?? []; + $baseUsd = isset($price['baseMoney']['amount']) ? (float) $price['baseMoney']['amount'] : null; + $finalUsd = isset($price['finalMoney']['amount']) ? (float) $price['finalMoney']['amount'] : null; + $cut = (int) rtrim((string) ($price['discount'] ?? '0%'), '%'); + + $genres = array_map(static fn ($x) => $x['name'], $p['genres'] ?? []); + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + + $doc = [ + 'slug' => $slug, + 'title' => $title, + 'base_price' => $baseUsd ?? 0, + 'genres' => array_values(array_unique(array_filter($genres))), + 'platforms' => ['gog'], + 'score' => (int) ($p['reviewsRating'] ?? 0), + 'rating_pct' => (int) ($p['reviewsRating'] ?? 0), + 'release_date' => $this->parseDate($p['releaseDate'] ?? null, 'Y.m.d'), + 'developer' => ($p['developers'][0] ?? null) ?: 'Unknown', + 'publisher' => ($p['publishers'][0] ?? null) ?: 'Unknown', + 'cover' => ['file' => $p['coverHorizontal'] ?? '', 'from' => '#862a52', 'to' => '#4b1530'], + 'description' => $p['description'] ?? '', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'gog', + ]; + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $r = Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $doc], ['upsert' => true]); + if ($r->getUpsertedCount() > 0) { + $inserted++; + } else { + $updated++; + } + + if ($finalUsd !== null && $baseUsd !== null && $baseUsd > 0) { + $hlPrice = $existing['all_time_low']['price'] ?? null; + Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'gog'], + ['$set' => [ + 'game_slug' => $slug, + 'title' => $title, + 'store_code' => 'gog', + 'kind' => 'official', + 'price' => round($finalUsd, 2), + 'regular' => round($baseUsd, 2), + 'cut' => max(0, min(95, $cut)), + 'historical_low' => $hlPrice !== null && $finalUsd <= $hlPrice + 0.01, + 'drm' => 'DRM-free', + 'regional' => false, + 'expiry' => null, + 'url' => $p['storeLink'] ?? 'https://www.gog.com/', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'gog', + ]], + ['upsert' => true] + ); + $dealsInserted++; + } + + usleep(100000); + } + } + + CLI::write("GOG done: {$inserted} inserted, {$updated} updated, {$dealsInserted} deals.", 'green'); + } + + // ------------------------------------------------------------------ Epic + + private function crawlEpic(): void + { + CLI::write('-- Epic free games --', 'cyan'); + + $data = $this->fetchJson(self::EPIC_FREE); + if ($data === null) { + CLI::error('Epic free fetch failed'); + + return; + } + + $elements = $data['data']['Catalog']['searchStore']['elements'] ?? []; + $now = time(); + $inserted = 0; + $dealsInserted = 0; + + foreach ($elements as $e) { + $title = $e['title'] ?? null; + if ($title === null) { + continue; + } + $slug = url_title($title, '-', true); + if ($slug === '') { + continue; + } + + $price = $e['price']['totalPrice']['fmtPrice'] ?? []; + $orig = (float) str_replace(['$', ','], '', (string) ($price['originalPrice'] ?? '0')); + $final = (float) str_replace(['$', ','], '', (string) ($price['discountPrice'] ?? '0')); + + $promos = $e['promotions'] ?? []; + $active = $promos['promotionalOffers'][0]['promotionalOffers'] ?? []; + $upcoming = $promos['upcomingPromotionalOffers'][0]['promotionalOffers'] ?? []; + $isFree = $final <= 0 || $active !== [] || $upcoming !== []; + + $existing = Mongo::collection('games')->findOne(['slug' => $slug]); + + $doc = [ + 'slug' => $slug, + 'title' => $title, + 'base_price' => $orig, + 'genres' => [], + 'platforms' => ['epic'], + 'score' => 0, + 'rating_pct' => 0, + 'release_date' => new UTCDateTime($now * 1000), + 'developer' => ($e['developer'] ?? 'Unknown') ?: 'Unknown', + 'publisher' => ($e['publisher'] ?? 'Unknown') ?: 'Unknown', + 'cover' => ['file' => $e['keyImages'][0]['url'] ?? '', 'from' => '#2b2b2b', 'to' => '#1a1a1a'], + 'description' => $e['description'] ?? '', + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'epic', + ]; + if ($existing) { + foreach (['all_time_low', 'best_official', 'best_keyshop', 'active_deals', 'trending'] as $f) { + if (isset($existing[$f])) { + $doc[$f] = $existing[$f]; + } + } + } + + $r = Mongo::collection('games')->updateOne(['slug' => $slug], ['$set' => $doc], ['upsert' => true]); + if ($r->getUpsertedCount() > 0) { + $inserted++; + } + + if ($final >= 0 && $orig > 0) { + $expiry = null; + foreach (array_merge($active, $upcoming) as $o) { + if (! empty($o['endDate'])) { + $expiry = new UTCDateTime(strtotime($o['endDate']) * 1000); + break; + } + } + Mongo::collection('deals')->updateOne( + ['game_slug' => $slug, 'store_code' => 'epic'], + ['$set' => [ + 'game_slug' => $slug, + 'title' => $title, + 'store_code' => 'epic', + 'kind' => 'official', + 'price' => round($final, 2), + 'regular' => round($orig, 2), + 'cut' => $isFree ? 100 : max(0, min(95, (int) round((1 - $final / $orig) * 100))), + 'historical_low' => $isFree, + 'drm' => 'Epic key', + 'regional' => false, + 'expiry' => $expiry, + 'url' => 'https://store.epicgames.com/p/' . ($e['productSlug'] ?? $slug), + 'last_crawled' => new UTCDateTime($now * 1000), + 'source' => 'epic', + ]], + ['upsert' => true] + ); + $dealsInserted++; + + // free game → news freebie post (only when free and not already posted) + if ($isFree && $existing === null) { + $newsSlug = 'freebie-' . $slug; + if (Mongo::collection('news')->findOne(['slug' => $newsSlug]) === null) { + Mongo::collection('news')->insertOne([ + 'title' => $title . ' is FREE to keep on Epic Games Store', + 'slug' => $newsSlug, + 'category' => 'freebies', + 'body' => "You can claim {$title} for free on the Epic Games Store right now — it's yours to keep forever once claimed. Head over to the store, sign in and hit the claim button before the offer expires.", + 'game_slug' => $slug, + 'date' => new UTCDateTime($now * 1000), + ]); + } + } + } + + usleep(150000); + } + + CLI::write("Epic done: {$inserted} games inserted, {$dealsInserted} deals.", 'green'); + } + + // ------------------------------------------------------------------ helpers + + private function fetchJson(string $url): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => self::UA, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($code !== 200 || $body === false) { + CLI::error("Fetch failed ({$code}): {$url}"); + + return null; + } + + $json = json_decode($body, true); + if (! is_array($json)) { + CLI::error('Invalid JSON: ' . substr($body, 0, 100)); + + return null; + } + + return $json; + } + + private function parseDate(?string $date, string $format = 'd M, Y'): ?UTCDateTime + { + if ($date === null || $date === '' || str_contains($date, 'Coming soon')) { + return null; + } + $ts = \DateTime::createFromFormat($format, $date); + if ($ts === false) { + try { + $ts = new \DateTime($date); + } catch (\Exception) { + return null; + } + } + + return new UTCDateTime($ts->getTimestamp() * 1000); + } +} diff --git a/app/Commands/CrawlGgdeals.php b/app/Commands/CrawlGgdeals.php new file mode 100644 index 0000000..3aabc7c --- /dev/null +++ b/app/Commands/CrawlGgdeals.php @@ -0,0 +1,329 @@ + $v) { + if (is_string($k) && str_starts_with($k, 'limit=')) { + $limit = (int) substr($k, 6); + } + if (is_string($k) && str_starts_with($k, 'slug=')) { + $slug = substr($k, 5); + } + if (is_string($k) && str_starts_with($k, 'batch=')) { + $batch = (int) substr($k, 6); + } + } + + CLI::write('GG.deals detailed crawler started — ' . date('Y-m-d H:i:s'), 'green'); + + // ensure session exists + $this->fsRequest(['cmd' => 'sessions.create', 'session' => self::SESSION]); + CLI::write('FlareSolverr session ready: ' . self::SESSION, 'green'); + + // ---- pick games to crawl ---- + $games = []; + + if ($slug !== null) { + // single game + $g = Mongo::collection('games')->findOne(['slug' => $slug]); + if ($g !== null) { + $games[] = $g; + } + } elseif ($batch !== null) { + // expand from catalog: next N slugs not yet detailed + CLI::write("Batch mode: expanding {$batch} games from catalog…", 'yellow'); + $slugs = Mongo::collection('catalog') + ->find(['crawled' => false], ['projection' => ['slug' => 1], 'sort' => ['_id' => 1], 'limit' => $batch]) + ->toArray(); + + foreach ($slugs as $c) { + $cslug = $c['slug']; + $existing = Mongo::collection('games')->findOne(['slug' => $cslug]); + if ($existing !== null) { + // already detailed; mark catalog crawled and skip + Mongo::collection('catalog')->updateOne(['slug' => $cslug], ['$set' => ['crawled' => true]]); + continue; + } + $games[] = ['slug' => $cslug, 'title' => $cslug]; + } + } else { + // refresh all games we already have + $filter = []; + $query = Mongo::collection('games')->find($filter, ['sort' => ['trending' => -1]]); + if ($limit !== null) { + $query = Mongo::collection('games')->find($filter, ['sort' => ['trending' => -1], 'limit' => $limit]); + } + $games = $query->toArray(); + } + + CLI::write('Games to crawl: ' . count($games), 'yellow'); + + $updated = 0; + $failed = 0; + + foreach ($games as $i => $game) { + $gslug = $game['slug']; + $url = 'https://gg.deals/game/' . $gslug . '/'; + + $result = $this->fsRequest([ + 'cmd' => 'request.get', + 'url' => $url, + 'session' => self::SESSION, + 'maxTimeout' => 30000, + 'disableMedia'=> true, + ]); + + $html = $result['solution']['response'] ?? null; + if ($html === null || $html === '' || str_contains($html, 'Just a moment')) { + CLI::error(" [{$i}] {$gslug}: failed (challenge or empty)"); + $failed++; + usleep(1000000); + continue; + } + + $parsed = $this->parseGamePage($html, $gslug, $game); + if ($parsed !== null) { + $this->applyToDb($gslug, $parsed); + $updated++; + CLI::write(" [{$i}] {$gslug}: saved (best \${$parsed['best_price']})"); + } else { + $failed++; + } + + // mark catalog as processed regardless (so we don't retry endlessly) + Mongo::collection('catalog')->updateOne( + ['slug' => $gslug], + ['$set' => ['crawled' => true, 'crawled_at' => new UTCDateTime(time() * 1000)]], + ['upsert' => true] + ); + + usleep(400000); + } + + // destroy session when done + $this->fsRequest(['cmd' => 'sessions.destroy', 'session' => self::SESSION]); + CLI::write("Done: {$updated} updated, {$failed} failed.", 'green'); + } + + /** + * Parse a gg.deals game page using its JSON-LD structured data. + * Returns best price + all offers from the AggregateOffer block. + */ + private function parseGamePage(string $html, string $slug, array $existing): ?array + { + // extract JSON-LD blocks + if (! preg_match_all('/ + + + + +
+
+ Displayed at — + PHP: — + CodeIgniter: -- + Environment: +
+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+

+ getMessage())) ?> + getMessage())) ?>" + rel="noreferrer" target="_blank">search → +

+
+
+ + +
+

at line

+ + +
+ +
+ +
+ +
+ getPrevious()) { + $last = $prevException; + ?> + +
+    Caused by:
+    getCode() ? ' #' . $prevException->getCode() : '') ?>
+
+    getMessage())) ?>
+    getMessage())) ?>"
+       rel="noreferrer" target="_blank">search →
+    getFile()) . ':' . $prevException->getLine()) ?>
+    
+ + +
+ + +
+ + + +
+ + +
+ +
    + $row) : ?> + +
  1. +

    + + + + + {PHP internal code} + + + + +   —   + + + ( arguments ) +

    + + + getParameters(); + } + + foreach ($row['args'] as $key => $value) : ?> + + + + + + +
    name : "#{$key}") ?>
    +
    + + () + + + + +   —   () + +

    + + + +
    + +
    + +
  2. + + +
+ +
+ + +
+ + + +

$

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + + +

Constants

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathgetUri()) ?>
HTTP MethodgetMethod()) ?>
IP AddressgetIPAddress()) ?>
Is AJAX Request?isAJAX() ? 'yes' : 'no' ?>
Is CLI Request?isCLI() ? 'yes' : 'no' ?>
Is Secure Request?isSecure() ? 'yes' : 'no' ?>
User AgentgetUserAgent()->getAgentString()) ?>
+ + + + + + + + +

$

+ + + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + + +
+ No $_GET, $_POST, or $_COOKIE Information to show. +
+ + + + headers(); ?> + + +

Headers

+ + + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getValueLine(), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ + +
+ + + setStatusCode(http_response_code()); + ?> +
+ + + + + +
Response StatusgetStatusCode() . ' - ' . $response->getReasonPhrase()) ?>
+ + headers(); ?> + +

Headers

+ + + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getHeaderLine($name), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ + +
+ + +
+ + +
    + +
  1. + +
+
+ + +
+ + + + + + + + + + + + + + + + +
Memory Usage
Peak Memory Usage:
Memory Limit:
+ +
+ +
+ +
+ + + + diff --git a/app/Views/errors/html/production.php b/app/Views/errors/html/production.php new file mode 100644 index 0000000..2f59a8d --- /dev/null +++ b/app/Views/errors/html/production.php @@ -0,0 +1,25 @@ + + + + + + + <?= lang('Errors.whoops') ?> + + + + + +
+ +

+ +

+ +
+ + + + diff --git a/app/Views/faq.php b/app/Views/faq.php new file mode 100644 index 0000000..d3ee427 --- /dev/null +++ b/app/Views/faq.php @@ -0,0 +1,45 @@ +extend('layout') ?> +section('content') ?> + +
+

Frequently Asked Questions

+ +
+ [$q, $a]): ?> +
+ +
+

+
+
+ +
+
+ +
+

Tutorials

+
+ + + 📖 + + + +
+
+ +endSection() ?> diff --git a/app/Views/franchise.php b/app/Views/franchise.php new file mode 100644 index 0000000..3fba673 --- /dev/null +++ b/app/Views/franchise.php @@ -0,0 +1,40 @@ +extend('layout') ?> +section('content') ?> + +
+

Franchise

+

All games in the series with current best prices.

+
+ + + + +
+

All franchises

+
+ + + () + + +
+
+ + +endSection() ?> diff --git a/app/Views/franchises.php b/app/Views/franchises.php new file mode 100644 index 0000000..c791692 --- /dev/null +++ b/app/Views/franchises.php @@ -0,0 +1,18 @@ +extend('layout') ?> +section('content') ?> + +
+

Franchises

+

Browse game series and franchises.

+
+ + + +endSection() ?> diff --git a/app/Views/game.php b/app/Views/game.php new file mode 100644 index 0000000..d47bf4d --- /dev/null +++ b/app/Views/game.php @@ -0,0 +1,601 @@ +extend('layout') ?> +section('content') ?> + += 90 ? 'Overwhelmingly positive' : ($ratingPct >= 75 ? 'Very positive' : ($ratingPct >= 50 ? 'Mostly positive' : 'Mixed')); +?> + + +
+ Deals + + +
+ + +
+
+ +
+ <?= esc($game['title']) ?> + +
+

+
+ MC + · % + + + + +
+ +
+ active offers + + official store + · keyshop + +
+ +
+ + +
+
+
+ + + + +
+
Best current deal
+
+
+
+ + 0): ?> + -% + +
+
regular price
+
+
+
+ $bestStore, 'size' => 'lg']) ?> +
+
+
+ +
+
+
+ + + Go to store → + + +
+
+ All-time low: + + at +
+
+ Reached () +
+
+
+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ Official Stores + 0): ?> + -% + +
+
+ + +
+
+ + $offStore]) ?> + +
+
+ + +
+
+ Keyshops + 0): ?> + -% + +
+
+ + +
+
+ + $keyStore]) ?> + +
+
+ + +
+ Subscriptions + +
+
+
included in the library
+ +
Not available
+ +
+ + +
+ All-time low +
+ + +
+
+ $hlStore]) ?> + +
+
+
+
+ +
+
+
+
Official Stores Low
+
+
Lowest official-store price right now
+
+
+
Keyshops Low
+
+
Lowest keyshop price right now
+
+
+
+
+ + +
+
+ + +
+ + +
+ +
+
+

Official stores

+ Verified & trusted +
+
+ +
No official store deals right now.
+ +
+ + + + + + + + + + + + + + + + + + + + + +
StoreDRMDiscountPrice
+
+ $ds]) ?> + + ★ HL +
+
+ 0): ?> + -% + + + + + + + + + toDateTime()->getTimestamp() - time()) / 86400)); ?> + ends in d + + Buy +
+
+ +
+
+ + +
+
+

Keyshops

+ Marketplace sellers +
+
+ +
No keyshop offers right now.
+ +
+ + + + + + + + + + + + + + + + + + + + + +
StoreEditionDiscountPrice
+
+ $ds]) ?> + + ★ HL +
+
+ Global + + · ⚠ region-locked + + + 0): ?> + -% + + + + + + + + Buy +
+
+ +
+ Keyshop offers are sold by marketplace sellers. GG.deals is not responsible for their content. +
+
+
+
+ + +
+
+
+

price history

+

Lowest recorded price per day

+
+
+ +
+
+
+
+
+
All-time low
+
+
·
+
+
+
Best official store now
+
+
+
+
+
Best keyshop now
+
+
+
+
+
+
+ + +
+
+

About

+

+
+ Developer: + Publisher: + Age rating: + Release: +
+
+ +
+

System Requirements

+ +
+ 'OS', 'cpu' => 'CPU', 'ram' => 'RAM', 'gpu' => 'GPU', 'storage' => 'Storage'] as $k => $label): ?> +
+ + +
+ +
+ +
+
How long to beat
+
+
h
Main
+
h
Completion
+
h
100%
+
+
+ +
+
+ + + +
+
+

Bundles with

+ See all → +
+ +
+ + + + +
+
+

Series & franchises

+
+
+ + + () + + +
+
+ + + + +
+
+

Similar games

+
+ +
+ + + + + +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/games.php b/app/Views/games.php new file mode 100644 index 0000000..d6bd265 --- /dev/null +++ b/app/Views/games.php @@ -0,0 +1,277 @@ +extend('layout') ?> +section('content') ?> + + $s['kind'] === 'official'); +$keyshopStores = array_filter($stores, static fn ($s) => $s['kind'] === 'keyshop'); +$selectedStores = $filters['stores']; +$selectedGenres = $filters['genres'] ?? []; +$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'] !== '' || $selectedGenres !== []; +$genreQuery = $activeGenre !== '' ? '?genre=' . urlencode($activeGenre) : ''; +?> + + +
+
+

+ +

+

+ games with deals · + games in database · + stores +

+
+
+ + +
+ + +
+ + +
+ Filters + + Reset + +
+ +
+ + +
+
Deal type
+
+ 'All deals', 'official' => 'Official', 'keyshop' => 'Keyshops'] as $k => $label): ?> + + +
+
+ + +
+
Price
+
+ 'Any', '2' => 'Under $2', '5' => 'Under $5', '10' => 'Under $10', '15' => 'Under $15', '20' => 'Under $20', '30' => 'Under $30']; + foreach ($priceTiers as $v => $l): ?> + + +
+
+ + + +
+
+ + +
+
Discount
+
+ 'Any', '50' => '≥50%', '66' => '≥66%', '75' => '≥75%', '80' => '≥80%', '85' => '≥85%', '90' => '≥90%']; + foreach ($discountTiers as $v => $l): ?> + + +
+
+
+ Minimum discount + +
+ +
+
+ + +
+
Deal rating
+
+ 'Any', 'hot' => '🔥 Hot', 'good' => 'Good', 'fair' => 'Fair']; + foreach ($ratings as $v => $l): ?> + + +
+
+ + +
+
Release year
+
+ 'Any', 'pre2000' => 'Before 2000', '2000s' => '2000–2009', '2010s' => '2010–2019', '2020s' => '2020+']; + foreach ($years as $v => $l): ?> + + +
+
+ + +
+
Genres
+
+ + +
+
+ $g): + $gl = mb_strtolower($g['name']); ?> + + +
+
+ + + + + +
+
Official stores
+
+ + + +
+
+ +
+
Keyshops
+
+ + + +
+
+ + +
+
+ + +
+ +
+
+ 'Best deals', + 'rating' => 'Deal rating', + 'cut' => 'Biggest discount', + 'hl' => 'Cheapest ever', + 'recent' => 'New releases', + ]; + foreach ($tabs as $key => $label): ?> + + + + +
+
+ + +
+
+ + +
+ +
+
🕹️
+
No games match your filters
+
Try widening the price range or clearing store filters.
+ Reset filters +
+ + + $row, 'stores' => $storesMap]) ?> + +
+ + + 1): ?> +
+ 1): ?> + ← Prev + 1): ?> + 1 + + + + + + + + Next → + +
+ +
+
+ +endSection() ?> diff --git a/app/Views/home.php b/app/Views/home.php new file mode 100644 index 0000000..5b0419e --- /dev/null +++ b/app/Views/home.php @@ -0,0 +1,332 @@ +extend('layout') ?> +section('content') ?> + + $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'] !== ''; +?> + + +
+
+

+ +

+

+ games · + deals from + stores · + all-time lows right now +

+
+ +
+ Search: + clear +
+ +
+ + +
+ '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): ?> + + + + +
+ + +
+
+

🔥 Trending deals

+
+ + +
+
+ +
+ + +
+ + +
+ + +
+ Filters + + Reset + +
+ +
+ + +
+
Deal type
+
+ 'All deals', 'official' => 'Official', 'keyshop' => 'Keyshops'] as $k => $label): ?> + + +
+
+ + +
+
Price
+
+ 'Any', '2' => 'Under $2', '5' => 'Under $5', '10' => 'Under $10', '15' => 'Under $15', '20' => 'Under $20', '30' => 'Under $30']; + foreach ($priceTiers as $v => $l): ?> + + +
+
+ + + +
+
+ + +
+
Discount
+
+ 'Any', '50' => '≥50%', '66' => '≥66%', '75' => '≥75%', '80' => '≥80%', '85' => '≥85%', '90' => '≥90%']; + foreach ($discountTiers as $v => $l): ?> + + +
+
+
+ Minimum discount + +
+ +
+
+ + +
+
Deal rating
+
+ 'Any', 'hot' => '🔥 Hot', 'good' => 'Good', 'fair' => 'Fair']; + foreach ($ratings as $v => $l): ?> + + +
+
+ + +
+
Release year
+
+ 'Any', 'pre2000' => 'Before 2000', '2000s' => '2000–2009', '2010s' => '2010–2019', '2020s' => '2020+']; + foreach ($years as $v => $l): ?> + + +
+
+ + + + + +
+ + Only games from wishlist +
+ + +
+
Official stores
+
+ + + +
+
+ +
+
Keyshops
+
+ + + +
+
+ + +
+
+ + +
+ +
+
+ 'Best deals', + 'rating' => 'Deal rating', + 'cut' => 'Biggest discount', + 'hl' => 'Cheapest ever', + 'recent' => 'New releases', + ]; + foreach ($tabs as $key => $label): ?> + + + + +
+
+ + +
+
+ + +
+ +
+
🕹️
+
No deals match your filters
+
Try widening the price range or clearing store filters.
+ Reset filters +
+ + + $row, 'stores' => $storesMap]) ?> + +
+ + + 1): ?> +
+ 1): ?> + ← Prev + 1): ?> + 1 + + + + + + + + Next → + +
+ +
+
+ +endSection() ?> diff --git a/app/Views/homepage.php b/app/Views/homepage.php new file mode 100644 index 0000000..c1fa858 --- /dev/null +++ b/app/Views/homepage.php @@ -0,0 +1,132 @@ +extend('layout') ?> +section('content') ?> + + ['Most Popular Games', 'popular', '/deals'], + 'newDeals' => ['New deals', 'new', '/deals/new-deals'], + 'bestDeals' => ['Best deals', 'hot', '/deals/hot-new-deals'], + 'histLows' => ['Historical lows', 'hl', '/deals/historical-lows'], + 'endingSoon' => ['Ending Soon', 'ending', '/deals/ending-soon'], +]; +?> + + +
+
+
+
Games tracked
+
+
+
+
Deals today
+
+
+
+
Stores
+
+
+
+
All-time lows now
+
+
+ Compare prices of video games from stores, + find the best game deals online. CD keys for PC games at the best price! +
+
+ + + [$title, $mode, $link]): ?> + +
+
+

+ See all → +
+
+ + $d, 'stores' => $stores]) ?> + +
+
+ + + + +
+ ['🔥 Most Wanted', 'wanted'], + 'newReleases' => ['🆕 New releases', 'releases'], + 'upcoming' => ['📅 Upcoming games', 'upcoming'], + ]; + foreach ($gameSections as $key => [$title, $mode]): ?> +
+
+

+ See all +
+
+ $g): ?> + + + +
+
+
+ + +
+
+
+ +
+
+ +
+ + +
+ +
+
+

📰 News

+ All news → +
+ +
+ + +
+
+

🎁 Prepaids

+ All → +
+ +
+
+ +endSection() ?> diff --git a/app/Views/layout.php b/app/Views/layout.php new file mode 100644 index 0000000..d29ae0d --- /dev/null +++ b/app/Views/layout.php @@ -0,0 +1,297 @@ + + + + + + <?= esc($pageTitle ?? 'GG.deals — best game deals') ?> + + + + + +
+ + +
+
+
+ PC deals + Console deals + Free games + Giveaways +
+
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ + + + + + + + Sign in +
+
+
+ + +
+
+ + GG.deals + + + +
+
+ + +
+
+ +
+
+ + + + + +
+
+ + +
+ renderSection('content') ?> +
+ + + +
+ + +renderSection('scripts') ?> + + diff --git a/app/Views/news.php b/app/Views/news.php new file mode 100644 index 0000000..b173168 --- /dev/null +++ b/app/Views/news.php @@ -0,0 +1,38 @@ +extend('layout') ?> +section('content') ?> + +
+

Video game news

+

Deals, bundles, freebies, giveaways and subscriptions — all in one feed.

+
+ + +
+ $label): ?> + + + + +
+ + + +endSection() ?> diff --git a/app/Views/news_article.php b/app/Views/news_article.php new file mode 100644 index 0000000..790560c --- /dev/null +++ b/app/Views/news_article.php @@ -0,0 +1,71 @@ +extend('layout') ?> +section('content') ?> + +
+ News + + +
+ +
+
+ + + · + +
+ +

+ + + + View related game page → + + + +
+

+ + +
+
+
FREE to keep — limited time
+
Claim it now before the offer expires
+
+ + Claim now → + +
+ + + +
+
+
Giveaway in progress
+
Enter now for a chance to win
+
+ +
+ +
+ + + +
+

More news

+
+ + +
+
+
+ +
+
+ +
+ +endSection() ?> diff --git a/app/Views/partials/dash_deal.php b/app/Views/partials/dash_deal.php new file mode 100644 index 0000000..00f3d63 --- /dev/null +++ b/app/Views/partials/dash_deal.php @@ -0,0 +1,30 @@ + store map + */ +$g = $d['game']; +$store = $stores[$d['best_store']] ?? null; +$year = isset($g['release_date']) && $g['release_date'] instanceof MongoDB\BSON\UTCDateTime + ? (string) $g['release_date']->toDateTime()->format('Y') + : ''; +?> +
+ + <?= esc($g['title']) ?> + +
+ +
+ $store]) ?> + +
+
+
+ 0): ?> +
-%
+ +
+
+
diff --git a/app/Views/partials/deal_row.php b/app/Views/partials/deal_row.php new file mode 100644 index 0000000..4f930d8 --- /dev/null +++ b/app/Views/partials/deal_row.php @@ -0,0 +1,90 @@ + 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; +?> +
+
+ + <?= esc($g['title']) ?> + + d + + + +
+
+
+
+ + ★ New HL + +
+
+ MC + + + +
+
+ +
+
+ 0): ?> + -% + + +
+
+
+
+ +
+ + Historical low + + at + + + + + ★ All-time low right now + +
+
+ + +
+
diff --git a/app/Views/partials/store_badge.php b/app/Views/partials/store_badge.php new file mode 100644 index 0000000..fc6126f --- /dev/null +++ b/app/Views/partials/store_badge.php @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/app/Views/prepaids.php b/app/Views/prepaids.php new file mode 100644 index 0000000..f7921dd --- /dev/null +++ b/app/Views/prepaids.php @@ -0,0 +1,33 @@ +extend('layout') ?> +section('content') ?> + +
+

Gift Cards & Prepaids

+

Buy digital gift cards for your favorite platforms at the best prices.

+
+ +
+ +
+
+ + + +
+
+
+ +
+ $ + + +
+ +
+
+ +
+ +endSection() ?> diff --git a/app/Views/rankings.php b/app/Views/rankings.php new file mode 100644 index 0000000..de68e24 --- /dev/null +++ b/app/Views/rankings.php @@ -0,0 +1,52 @@ +extend('layout') ?> +section('content') ?> + +
+

Rankings

+

Top games by wishlist, collection, critic scores and community reviews.

+
+ + ['🔥 Most Wishlisted', $wishlisted, 'trending'], + 'collected' => ['📦 Most Collected', $collected, 'rating_pct'], + 'metacritic' => ['⭐ Metacritic', $metacritic, 'score'], + 'reviews' => ['💬 Steam Reviews', $reviews, 'rating_pct'], +]; +?> + +
+ [$title, $list, $field]): ?> +
+
+

+ Top +
+ +
+ +
+ +endSection() ?> diff --git a/app/Views/stores.php b/app/Views/stores.php new file mode 100644 index 0000000..f3bc5ae --- /dev/null +++ b/app/Views/stores.php @@ -0,0 +1,40 @@ +extend('layout') ?> +section('content') ?> + +
+

Stores

+

All stores tracked by GG.deals — official stores and keyshop marketplaces.

+
+ + + +endSection() ?> diff --git a/app/Views/subscriptions.php b/app/Views/subscriptions.php new file mode 100644 index 0000000..9f0e017 --- /dev/null +++ b/app/Views/subscriptions.php @@ -0,0 +1,34 @@ +extend('layout') ?> +section('content') ?> + +
+

Subscriptions

+

Game subscription services — get hundreds of games for a monthly fee.

+
+ +
+ +
+
+ + + + +
+
+

+
+ + per month +
+ +
+ $st]) ?> + +
+
+ +
+ +endSection() ?> diff --git a/app/Views/tutorial.php b/app/Views/tutorial.php new file mode 100644 index 0000000..27077dc --- /dev/null +++ b/app/Views/tutorial.php @@ -0,0 +1,82 @@ +extend('layout') ?> +section('content') ?> + +
+ FAQ + + +
+ +
+

+ + [ + 'Open the Steam client on your computer and sign in.', + 'Click "Games" in the top menu, then "Activate a Product on Steam".', + 'Click "Next" and agree to the Steam Subscriber Agreement.', + 'Enter the CD key you purchased — it looks like XXXXX-XXXXX-XXXXX.', + 'Click "Next". The game will be added to your library and begin downloading automatically.', + 'You can also do this in the browser: store.steampowered.com/account/registerkey.', + ], + 'gog' => [ + 'Sign in to your GOG.com account in the browser or GOG Galaxy client.', + 'In the browser, hover over your username and click "Account".', + 'Click "Redeem code" in the account menu.', + 'Enter the code from your purchase and click "Redeem".', + 'The game is added to your library. In GOG Galaxy it appears immediately — install from the library tab.', + ], + 'epic' => [ + 'Open the Epic Games Launcher and sign in.', + 'Click your profile icon (top right) and select "Redeem Code".', + 'Enter the code — it is usually 5 groups of 5 characters.', + 'Click "Redeem". The game is added to your library.', + 'Find it under your library and click "Install" to download.', + ], + 'origin' => [ + 'Open the EA app (formerly Origin) and sign in.', + 'Click "Menu" (≡) in the top-left, then "Redeem Product Code".', + 'Enter the code and click "Next".', + 'The game appears in your library. Select it and click "Download".', + ], + 'uplay' => [ + 'Open Ubisoft Connect (Uplay) and sign in.', + 'Click "Games" then "Activate a key" in the top bar.', + 'Enter your product key.', + 'Click "Activate". The game is added to your library.', + 'Ubisoft Connect also supports claiming keys directly on account.ubisoft.com.', + ], + 'keyshops' => [ + 'Keyshops are marketplaces where third-party sellers list game keys.', + 'They are usually cheaper than official stores — sometimes significantly.', + 'The main risks: region-locked keys that may not activate in your country, and keys purchased with stolen payment methods that can be revoked later.', + 'Always check the key region before buying. Global keys are the safest option.', + 'You can disable keyshop prices entirely in the site settings (Keyshops toggle).', + 'If a key fails to activate, contact the seller first — most marketplaces have buyer protection.', + ], + }; + ?> +
    + $step): ?> +
  1. + + +
  2. + +
+
+ +
+

Other tutorials

+
+ [$title, $plat]) : if ($key === array_search($tutorial, $all, true)) continue; ?> + + 📖 + + + +
+
+ +endSection() ?> diff --git a/app/Views/vouchers.php b/app/Views/vouchers.php new file mode 100644 index 0000000..ed3c355 --- /dev/null +++ b/app/Views/vouchers.php @@ -0,0 +1,58 @@ +extend('layout') ?> +section('content') ?> + +
+

Promo Codes & Coupons

+

Active discount codes across all stores. Copy a code and apply it at checkout.

+
+ + +
+
🎟️
+
No active vouchers for this store
+
+ +
+ toDateTime()->getTimestamp() - time()) / 86400)) + : null; + ?> +
+
+ $store, 'size' => 'lg']) ?> +
+
+
+
+
+ +
+ + +
+ +
+ -% + 0): ?>min. + expires in d +
+ +

+ +
+ +
+ + +endSection() ?> diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php new file mode 100644 index 0000000..c18eca3 --- /dev/null +++ b/app/Views/welcome_message.php @@ -0,0 +1,331 @@ + + + + + Welcome to CodeIgniter 4! + + + + + + + + + + + +
+ + + +
+ +

Welcome to CodeIgniter

+ +

The small framework with powerful features

+ +
+ +
+ + + +
+ +

About this page

+ +

The page you are looking at is being generated dynamically by CodeIgniter.

+ +

If you would like to edit this page you will find it located at:

+ +
app/Views/welcome_message.php
+ +

The corresponding controller for this page can be found at:

+ +
app/Controllers/Home.php
+ +
+ +
+ +
+ +

Go further

+ +

+ + Learn +

+ +

The User Guide contains an introduction, tutorial, a number of "how to" + guides, and then reference documentation for the components that make up + the framework. Check the User Guide !

+ +

+ + Discuss +

+ +

CodeIgniter is a community-developed open source project, with several + venues for the community members to gather and exchange ideas. View all + the threads on CodeIgniter's forum, or chat on Slack !

+ +

+ + Contribute +

+ +

CodeIgniter is a community driven project and accepts contributions + of code and documentation from the community. Why not + + join us ?

+ +
+ +
+ + + +
+
+ +

Page rendered in {elapsed_time} seconds using {memory_usage} MB of memory.

+ +

Environment:

+ +
+ +
+ +

© CodeIgniter Foundation. CodeIgniter is open source project released under the MIT + open source licence.

+ +
+ +
+ + + + + + + + + diff --git a/app/Views/wishlist.php b/app/Views/wishlist.php new file mode 100644 index 0000000..71d7d65 --- /dev/null +++ b/app/Views/wishlist.php @@ -0,0 +1,71 @@ +extend('layout') ?> +section('content') ?> + +
+

Your wishlist

+

+ games saved · tracked in your browser +

+
+ +
+
Loading…
+ +
+
💜
+
Your wishlist is empty
+

Browse the deals and click the heart on any game to save it here.

+ Browse deals +
+ +
+ +
+
+ +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..69df4e1 --- /dev/null +++ b/app/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..5311bcf --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,288 @@ +@import "tailwindcss"; + +@source "../../app/Views"; + +@theme { + --color-page: #13131c; + --color-panel: #1a1a26; + --color-card: #20202d; + --color-elev: #2a2a3c; + --color-line: #2e2e42; + --color-muted: #9b9bb0; + --color-accent: #8be04b; + --color-accent-ink: #12290a; + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +[x-cloak] { display: none !important; } + +html { color-scheme: dark; } + +::selection { background: rgba(139, 224, 75, 0.35); } + +/* ---------- scrollbars ---------- */ +.gg-scroll { scrollbar-width: thin; scrollbar-color: #3a3a52 transparent; } +.gg-scroll::-webkit-scrollbar { height: 8px; width: 8px; } +.gg-scroll::-webkit-scrollbar-thumb { background: #3a3a52; border-radius: 8px; } +.gg-scroll::-webkit-scrollbar-track { background: transparent; } + +/* ---------- discount badges ---------- */ +.cut { color: #dff5c8; } +.cut-lo { background: #3f7d29; } +.cut-mid { background: #4f9d2c; } +.cut-hi { background: #63bd38; } +.cut-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; +} + +/* ---------- score / chips ---------- */ +.score-chip { + display: inline-block; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.02em; + color: #d8db9a; + background: #46442a; + border: 1px solid #5d5a33; + padding: 3px 7px; + border-radius: 6px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: #b9b9c9; + background: #26263a; + border: 1px solid #34344c; + padding: 3px 10px; + border-radius: 999px; + transition: border-color .15s; +} +.chip-green { color: #a9e77c; border-color: #466331; background: #24331c; } +.chip-amber { color: #ecc978; border-color: #6b5525; background: #362c17; } + +/* ---------- store badges ---------- */ +.store-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + flex-shrink: 0; + overflow: hidden; +} +.store-badge-logo { + width: 100%; + height: 100%; + object-fit: contain; + padding: 3px; +} +.store-badge.lg { + width: 34px; + height: 34px; + border-radius: 8px; +} +.store-badge.lg .store-badge-logo { + padding: 5px; +} + +/* ---------- deal ratings ---------- */ +.rating { + display: inline-block; + font-size: 11px; + font-weight: 800; + padding: 2.5px 8px; + border-radius: 999px; +} +.rating-best { background: #2c5e14; color: #b7f383; } +.rating-great { background: #38561f; color: #aede7d; } +.rating-good { background: #565120; color: #e4d97e; } +.rating-fair { background: #5d4719; color: #ecc978; } +.rating-high { background: #5e2424; color: #f09c9c; } + +.hl-flag { + font-size: 10px; + font-weight: 800; + color: #ffd86e; + background: rgba(255, 216, 110, 0.09); + border: 1px solid rgba(255, 216, 110, 0.35); + padding: 2px 7px; + border-radius: 999px; +} + +/* ---------- kind tags ---------- */ +.kind-tag { + display: inline-block; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2.5px 7px; + border-radius: 5px; +} +.kind-official { color: #a9e77c; background: rgba(139, 224, 75, 0.1); border: 1px solid rgba(139, 224, 75, 0.3); } +.kind-keyshop { color: #ffc896; background: rgba(255, 166, 87, 0.1); border: 1px solid rgba(255, 166, 87, 0.3); } + +/* ---------- filters ---------- */ +.filter-label { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #83839c; +} +.filter-input { + width: 100%; + background: #16161f; + border: 1px solid #2e2e42; + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: #d4d4e2; + outline: none; +} +.filter-input:focus { border-color: rgba(139, 224, 75, 0.6); } +.filter-input::placeholder { color: #5c5c74; } + +.preset-chip { + font-size: 10.5px; + font-weight: 600; + color: #9b9bb0; + background: #16161f; + border: 1px solid #2e2e42; + padding: 3px 8px; + border-radius: 999px; + transition: all .15s; +} +.preset-chip:hover { color: #8be04b; border-color: rgba(139, 224, 75, 0.5); } +.preset-chip.chip-active { + color: #8be04b; + border-color: rgba(139, 224, 75, 0.55); + background: rgba(139, 224, 75, 0.08); +} + +.store-row { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 7px; + border-radius: 8px; + transition: background .15s; +} +.store-row:hover { background: #26263a; } + +/* range slider */ +.gg-range { -webkit-appearance: none; appearance: none; height: 5px; background: #2e2e42; border-radius: 999px; outline: none; } +.gg-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: #8be04b; + border: 2px solid #12290a; + cursor: pointer; +} +.gg-range::-moz-range-thumb { + width: 14px; height: 14px; + border-radius: 50%; + background: #8be04b; + border: 2px solid #12290a; + cursor: pointer; +} + +/* checkbox */ +.gg-check { + appearance: none; + width: 15px; height: 15px; + border: 1.5px solid #454563; + border-radius: 4.5px; + background: #16161f; + cursor: pointer; + flex-shrink: 0; + position: relative; +} +.gg-check:checked { + background: #8be04b; + border-color: #8be04b; +} +.gg-check:checked::after { + content: ""; + position: absolute; + left: 4px; top: 1px; + width: 4px; height: 8px; + border: solid #12290a; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* ---------- buttons ---------- */ +.btn-buy { + display: inline-block; + font-size: 12px; + font-weight: 800; + color: #12290a; + background: #8be04b; + padding: 6px 16px; + border-radius: 7px; + transition: background .15s; +} +.btn-buy:hover { background: #9cf465; } + +/* ---------- pagination ---------- */ +.pg-btn { + display: inline-block; + font-size: 12.5px; + font-weight: 700; + color: #b9b9c9; + background: #20202d; + border: 1px solid #2e2e42; + padding: 7px 13px; + border-radius: 8px; + transition: all .15s; +} +.pg-btn:hover { color: #8be04b; border-color: rgba(139, 224, 75, 0.5); } +.pg-active { color: #12290a; background: #8be04b; border-color: #8be04b; } +.pg-active:hover { color: #12290a; } +.pg-dots { color: #5c5c74; padding: 7px 2px; } + +/* ---------- light theme ---------- */ +html.theme-light { + color-scheme: light; +} +html.theme-light { + --color-page: #f4f4f7; + --color-panel: #ffffff; + --color-card: #ffffff; + --color-elev: #e8e8ef; + --color-line: #d8d8e2; + --color-muted: #6b6b80; + --color-accent: #4c9e1f; + --color-accent-ink: #ffffff; +} +html.theme-light body { + background: var(--color-page); + color: #27273a; +} +html.theme-light .text-zinc-100, html.theme-light .text-zinc-200, html.theme-light .text-white { color: #1c1c2e !important; } +html.theme-light .text-zinc-300, html.theme-light .text-zinc-400 { color: #4a4a5e !important; } +html.theme-light .bg-page { background: #f4f4f7 !important; } +html.theme-light .bg-panel { background: #ffffff !important; } +html.theme-light .bg-card { background: #ffffff !important; } +html.theme-light .hover\:bg-elev:hover { background: #e8e8ef !important; } +html.theme-light .border-line { border-color: #d8d8e2 !important; } +html.theme-light .filter-input, html.theme-light .preset-chip { background: #f4f4f7; border-color: #d8d8e2; color: #4a4a5e; } +html.theme-light .chip { background: #f0f0f5; border-color: #d8d8e2; color: #4a4a5e; } +html.theme-light .score-chip { background: #e8e8c0; border-color: #c0c080; color: #5a5a20; } diff --git a/builds b/builds new file mode 100755 index 0000000..c68c089 --- /dev/null +++ b/builds @@ -0,0 +1,128 @@ +#!/usr/bin/env php + 'vcs', + 'url' => GITHUB_URL, + ]; + } + + $array['require']['codeigniter4/codeigniter4'] = $branch === 'next' ? NEXT_MINOR : DEVELOP_BRANCH; + unset($array['require']['codeigniter4/framework']); + } else { + unset($array['minimum-stability']); + + if (isset($array['repositories'])) { + foreach ($array['repositories'] as $i => $repository) { + if ($repository['url'] === GITHUB_URL) { + unset($array['repositories'][$i]); + break; + } + } + + if ($array['repositories'] === []) { + unset($array['repositories']); + } + } + + $array['require']['codeigniter4/framework'] = LATEST_RELEASE; + unset($array['require']['codeigniter4/codeigniter4']); + } + + file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $modified[] = $file; + } else { + echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL; + } + } else { + echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL; + } +} + +$files = [ + __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.dist.xml', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', +]; + +foreach ($files as $file) { + if (is_file($file)) { + $contents = file_get_contents($file); + + if ($branch !== 'release') { + $contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents); + } else { + $contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents); + } + + file_put_contents($file, $contents); + + $modified[] = $file; + } +} + +if ($modified === []) { + echo 'No files modified.' . PHP_EOL; +} else { + echo 'The following files were modified:' . PHP_EOL; + + foreach ($modified as $file) { + echo " * {$file}" . PHP_EOL; + } +} + +echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL; +echo 'Don\'t forget to update the project files in app folder from "vendor/codeigniter4/*/app/".' . PHP_EOL; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e53781f --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "codeigniter4/appstarter", + "description": "CodeIgniter4 starter app", + "license": "MIT", + "type": "project", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "source": "https://github.com/codeigniter4/CodeIgniter4", + "slack": "https://codeigniterchat.slack.com" + }, + "require": { + "php": "^8.2", + "codeigniter4/framework": "^4.7", + "mongodb/mongodb": "^2.4" + }, + "require-dev": { + "fakerphp/faker": "^1.9", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "^10.5.16" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Config\\": "app/Config/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\Support\\": "tests/_support" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d91af95 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2254 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d571d034233db836074d13ad7bd85c82", + "packages": [ + { + "name": "codeigniter4/framework", + "version": "v4.7.4", + "source": { + "type": "git", + "url": "https://github.com/codeigniter4/framework.git", + "reference": "67ead895b7491703e5e5bc17436778806192008f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/codeigniter4/framework/zipball/67ead895b7491703e5e5bc17436778806192008f", + "reference": "67ead895b7491703e5e5bc17436778806192008f", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "ext-mbstring": "*", + "laminas/laminas-escaper": "^2.18", + "php": "^8.2", + "psr/log": "^3.0" + }, + "require-dev": { + "codeigniter/coding-standard": "^1.7", + "fakerphp/faker": "^1.24", + "friendsofphp/php-cs-fixer": "^3.47.1", + "kint-php/kint": "^6.1", + "mikey179/vfsstream": "^1.6.12", + "nexusphp/cs-config": "^3.6", + "phpunit/phpunit": "^10.5.16 || ^11.2", + "predis/predis": "^3.0" + }, + "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", + "ext-curl": "If you use CURLRequest class", + "ext-dom": "If you use TestResponse", + "ext-exif": "If you run Image class tests", + "ext-fileinfo": "Improves mime type detection for files", + "ext-gd": "If you use Image class GDHandler", + "ext-imagick": "If you use Image class ImageMagickHandler", + "ext-libxml": "If you use TestResponse", + "ext-memcache": "If you use Cache class MemcachedHandler with Memcache", + "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", + "ext-mysqli": "If you use MySQL", + "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", + "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", + "ext-readline": "Improves CLI::input() usability", + "ext-redis": "If you use Cache class RedisHandler", + "ext-simplexml": "If you format XML", + "ext-sodium": "If you use Encryption SodiumHandler", + "ext-sqlite3": "If you use SQLite3", + "ext-sqlsrv": "If you use SQL Server", + "ext-xdebug": "If you use CIUnitTestCase::assertHeaderEmitted()" + }, + "type": "project", + "autoload": { + "psr-4": { + "CodeIgniter\\": "system/" + }, + "exclude-from-classmap": [ + "**/Database/Migrations/**" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The CodeIgniter framework v4", + "homepage": "https://codeigniter.com", + "support": { + "forum": "https://forum.codeigniter.com/", + "slack": "https://codeigniterchat.slack.com", + "source": "https://github.com/codeigniter4/CodeIgniter4" + }, + "time": "2026-07-07T09:23:35+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-14T18:31:13+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library", + "reference": "066ee1fd2ed0418798d4e024299b33f75f915bb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/066ee1fd2ed0418798d4e024299b33f75f915bb4", + "reference": "066ee1fd2ed0418798d4e024299b33f75f915bb4", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.4", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.33" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.3.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "~6.14.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "time": "2026-08-18T14:20:54+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.12", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5||^8.5||^9.6", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "support": { + "issues": "https://github.com/bovigo/vfsStream/issues", + "source": "https://github.com/bovigo/vfsStream/tree/master", + "wiki": "https://github.com/bovigo/vfsStream/wiki" + }, + "time": "2024-08-29T18:43:31+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:27:39+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/env b/env new file mode 100644 index 0000000..f359ec2 --- /dev/null +++ b/env @@ -0,0 +1,69 @@ +#-------------------------------------------------------------------- +# Example Environment Configuration file +# +# This file can be used as a starting point for your own +# custom .env files, and contains most of the possible settings +# available in a default install. +# +# By default, all of the settings are commented out. If you want +# to override the setting, you must un-comment it by removing the '#' +# at the beginning of the line. +#-------------------------------------------------------------------- + +#-------------------------------------------------------------------- +# ENVIRONMENT +#-------------------------------------------------------------------- + +# CI_ENVIRONMENT = production + +#-------------------------------------------------------------------- +# APP +#-------------------------------------------------------------------- + +# app.baseURL = '' +# If you have trouble with `.`, you could also use `_`. +# app_baseURL = '' +# app.forceGlobalSecureRequests = false +# app.CSPEnabled = false + +#-------------------------------------------------------------------- +# DATABASE +#-------------------------------------------------------------------- + +# database.default.hostname = localhost +# database.default.database = ci4 +# database.default.username = root +# database.default.password = root +# database.default.DBDriver = MySQLi +# database.default.DBPrefix = +# database.default.port = 3306 + +# If you use MySQLi as tests, first update the values of Config\Database::$tests. +# database.tests.hostname = localhost +# database.tests.database = ci4_test +# database.tests.username = root +# database.tests.password = root +# database.tests.DBDriver = MySQLi +# database.tests.DBPrefix = +# database.tests.charset = utf8mb4 +# database.tests.DBCollat = utf8mb4_general_ci +# database.tests.port = 3306 + +#-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = + +#-------------------------------------------------------------------- +# SESSION +#-------------------------------------------------------------------- + +# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' +# session.savePath = null + +#-------------------------------------------------------------------- +# LOGGER +#-------------------------------------------------------------------- + +# logger.threshold = 4 diff --git a/package.json b/package.json new file mode 100644 index 0000000..2542626 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "ggdeals-clone", + "private": true, + "type": "module", + "dependencies": { + "chart.js": "^4.4.0", + "alpinejs": "^3.14.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0", + "tailwindcss": "^4.0.0" + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..d9d2c6a --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + ./tests + + + + + + + + + + + + ./app + + + ./app/Views + ./app/Config/Routes.php + + + + + + + + + + + + + + + + diff --git a/preload.php b/preload.php new file mode 100644 index 0000000..288b30b --- /dev/null +++ b/preload.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + *--------------------------------------------------------------- + * Sample file for Preloading + *--------------------------------------------------------------- + * See https://www.php.net/manual/en/opcache.preloading.php + * + * How to Use: + * 0. Copy this file to your project root folder. + * 1. Set the $paths property of the preload class below. + * 2. Set opcache.preload in php.ini. + * php.ini: + * opcache.preload=/path/to/preload.php + */ + +// Load the paths config file +require __DIR__ . '/app/Config/Paths.php'; + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +class preload +{ + /** + * @var array Paths to preload. + */ + private array $paths = [ + [ + 'include' => __DIR__ . '/vendor/codeigniter4/framework/system', // Change this path if using manual installation + 'exclude' => [ + // Not needed if you don't use them. + '/system/Database/OCI8/', + '/system/Database/Postgre/', + '/system/Database/SQLite3/', + '/system/Database/SQLSRV/', + // Not needed for web apps. + '/system/Database/Seeder.php', + '/system/Test/', + '/system/CLI/', + '/system/Commands/', + '/system/Publisher/', + '/system/ComposerScripts.php', + // Not Class/Function files. + '/system/Config/Routes.php', + '/system/Language/', + '/system/bootstrap.php', + '/system/util_bootstrap.php', + '/system/rewrite.php', + '/Views/', + // Errors occur. + '/system/ThirdParty/', + ], + ], + ]; + + public function __construct() + { + $this->loadAutoloader(); + } + + private function loadAutoloader(): void + { + $paths = new Paths(); + require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'Boot.php'; + + Boot::preload($paths); + } + + /** + * Load PHP files. + */ + public function load(): void + { + foreach ($this->paths as $path) { + $directory = new RecursiveDirectoryIterator($path['include']); + $fullTree = new RecursiveIteratorIterator($directory); + $phpFiles = new RegexIterator( + $fullTree, + '/.+((? $file) { + foreach ($path['exclude'] as $exclude) { + if (str_contains($file[0], $exclude)) { + continue 2; + } + } + + require_once $file[0]; + // Uncomment only for debugging (to inspect which files are included). + // Never use this in production - preload scripts must not generate output. + // echo 'Loaded: ' . $file[0] . "\n"; + } + } + } +} + +(new preload())->load(); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..abac3cb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,49 @@ +# Disable directory browsing +Options -Indexes + +# ---------------------------------------------------------------------- +# Rewrite engine +# ---------------------------------------------------------------------- + +# Turning on the rewrite engine is necessary for the following rules and features. +# FollowSymLinks must be enabled for this to work. + + Options +FollowSymlinks + RewriteEngine On + + # If you installed CodeIgniter in a subfolder, you will need to + # change the following line to match the subfolder you need. + # http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase + # RewriteBase / + + # Redirect Trailing Slashes... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Rewrite "www.example.com -> example.com" + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + # Checks to see if the user is attempting to access a valid file, + # such as an image or css document, if this isn't true it sends the + # request to the front controller, index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA] + + # Ensure Authorization header is passed along + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + + + # If we don't have mod_rewrite installed, all 404's + # can be sent to index.php, and everything works as normal. + ErrorDocument 404 index.php + + +# Disable server signature start +ServerSignature Off +# Disable server signature end diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..7ecfce2 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..25faf0b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + GG + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8e834f2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,59 @@ +systemDirectory . '/Boot.php'; + +exit(Boot::bootWeb($paths)); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..9e60f97 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/scripts/scheduler_loop.php b/scripts/scheduler_loop.php new file mode 100644 index 0000000..4cecaee --- /dev/null +++ b/scripts/scheduler_loop.php @@ -0,0 +1,91 @@ +#!/usr/bin/env php + 0 && posix_kill($pid, 0)) { + return false; // another instance alive + } + @unlink($lockFile); // stale lock + } + file_put_contents($lockFile, (string) getmypid()); + + return true; +} + +function releaseLock(string $lockFile): void +{ + @unlink($lockFile); +} + +function run(string $root, string $cmd, string $logFile, string $tag): int +{ + logMsg($logFile, ">>> [$tag] $cmd"); + $output = []; + $code = 0; + exec('cd ' . escapeshellarg($root) . ' && ' . $cmd . ' 2>&1', $output, $code); + foreach (array_slice($output, -6) as $line) { + logMsg($logFile, " $line"); + } + logMsg($logFile, "<<< [$tag] exit $code"); + + return $code; +} + +logMsg($logFile, "Scheduler loop started — interval {$intervalMin}min, batch {$batchSize}"); + +while (true) { + if (! acquireLock($lockFile)) { + logMsg($logFile, 'Another crawler instance is running — exiting this loop.'); + exit(1); + } + + try { + // 1. refresh prices from direct sources + run($root, 'php spark gg:crawl steam', $logFile, 'steam'); + run($root, 'php spark gg:crawl gog', $logFile, 'gog'); + run($root, 'php spark gg:crawl epic', $logFile, 'epic'); + run($root, 'php spark gg:crawl finalize', $logFile, 'finalize'); + + // 2. expand catalog via FlareSolverr session + run($root, "php spark gg:ggdeals --batch={$batchSize}", $logFile, 'ggdeals-expand'); + } catch (\Throwable $e) { + logMsg($logFile, 'ERROR in loop: ' . $e->getMessage()); + } finally { + releaseLock($lockFile); + } + + logMsg($logFile, "Sleeping {$intervalMin}min until next cycle…"); + sleep($intervalMin * 60); +} diff --git a/spark b/spark new file mode 100755 index 0000000..61bc572 --- /dev/null +++ b/spark @@ -0,0 +1,87 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Boot; +use Config\Paths; + +/* + * -------------------------------------------------------------------- + * CODEIGNITER COMMAND-LINE TOOLS + * -------------------------------------------------------------------- + * The main entry point into the CLI system and allows you to run + * commands and perform maintenance on your application. + */ + +/* + *--------------------------------------------------------------- + * CHECK SERVER API + *--------------------------------------------------------------- + */ + +// Refuse to run when called from php-cgi +if (str_starts_with(PHP_SAPI, 'cgi')) { + exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n"); +} + +/* + *--------------------------------------------------------------- + * CHECK PHP VERSION + *--------------------------------------------------------------- + */ + +$minPhpVersion = '8.2'; // If you update this, don't forget to update `public/index.php`. +if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { + $message = sprintf( + 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', + $minPhpVersion, + PHP_VERSION, + ); + + exit($message); +} + +// We want errors to be shown when using it from the CLI. +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +/* + *--------------------------------------------------------------- + * SET THE CURRENT DIRECTORY + *--------------------------------------------------------------- + */ + +// Path to the front controller +define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR); + +// Ensure the current directory is pointing to the front controller's directory +chdir(FCPATH); + +/* + *--------------------------------------------------------------- + * BOOTSTRAP THE APPLICATION + *--------------------------------------------------------------- + * This process sets up the path constants, loads and registers + * our autoloader, along with Composer's, loads our constants + * and fires up an environment-specific bootstrapping. + */ + +// LOAD OUR PATHS CONFIG FILE +// This is the line that might need to be changed, depending on your folder structure. +require FCPATH . '../app/Config/Paths.php'; +// ^^^ Change this line if you move your application folder + +$paths = new Paths(); + +// LOAD THE FRAMEWORK BOOTSTRAP FILE +require $paths->systemDirectory . '/Boot.php'; + +exit(Boot::bootSpark($paths)); diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d5b12ee --- /dev/null +++ b/tests/README.md @@ -0,0 +1,118 @@ +# Running Application Tests + +This is the quick-start to CodeIgniter testing. Its intent is to describe what +it takes to set up your application and get it ready to run unit tests. +It is not intended to be a full description of the test features that you can +use to test your application. Those details can be found in the documentation. + +## Resources + +* [CodeIgniter 4 User Guide on Testing](https://codeigniter.com/user_guide/testing/index.html) +* [PHPUnit docs](https://phpunit.de/documentation.html) +* [Any tutorials on Unit testing in CI4?](https://forum.codeigniter.com/showthread.php?tid=81830) + +## Requirements + +It is recommended to use the latest version of PHPUnit. At the time of this +writing, we are running version 9.x. Support for this has been built into the +**composer.json** file that ships with CodeIgniter and can easily be installed +via [Composer](https://getcomposer.org/) if you don't already have it installed globally. + +```console +> composer install +``` + +If running under macOS or Linux, you can create a symbolic link to make running tests a touch nicer. + +```console +> ln -s ./vendor/bin/phpunit ./phpunit +``` + +You also need to install [XDebug](https://xdebug.org/docs/install) in order +for code coverage to be calculated successfully. After installing `XDebug`, you must add `xdebug.mode=coverage` in the **php.ini** file to enable code coverage. + +## Setting Up + +A number of the tests use a running database. +In order to set up the database edit the details for the `tests` group in +**app/Config/Database.php** or **.env**. +Make sure that you provide a database engine that is currently running on your machine. +More details on a test database setup are in the +[Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) section of the documentation. + +## Running the tests + +The entire test suite can be run by simply typing one command-line command from the main directory. + +```console +> ./phpunit +``` + +If you are using Windows, use the following command. + +```console +> vendor\bin\phpunit +``` + +You can limit tests to those within a single test directory by specifying the +directory name after phpunit. + +```console +> ./phpunit app/Models +``` + +## Generating Code Coverage + +To generate coverage information, including HTML reports you can view in your browser, +you can use the following command: + +```console +> ./phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m +``` + +This runs all of the tests again collecting information about how many lines, +functions, and files are tested. It also reports the percentage of the code that is covered by tests. +It is collected in two formats: a simple text file that provides an overview as well +as a comprehensive collection of HTML files that show the status of every line of code in the project. + +The text file can be found at **tests/coverage.txt**. +The HTML files can be viewed by opening **tests/coverage/index.html** in your favorite browser. + +## PHPUnit XML Configuration + +The repository has a ``phpunit.dist.xml`` file in the project root that's used for +PHPUnit configuration. This is used to provide a default configuration if you +do not have your own configuration file in the project root. + +The normal practice would be to copy ``phpunit.dist.xml`` to ``phpunit.xml`` +(which is git ignored), and to tailor it as you see fit. +For instance, you might wish to exclude database tests, or automatically generate +HTML code coverage reports. + +## Test Cases + +Every test needs a *test case*, or class that your tests extend. CodeIgniter 4 +provides one class that you may use directly: +* `CodeIgniter\Test\CIUnitTestCase` + +Most of the time you will want to write your own test cases that extend `CIUnitTestCase` +to hold functions and services common to your test suites. + +## Creating Tests + +All tests go in the **tests/** directory. Each test file is a class that extends a +**Test Case** (see above) and contains methods for the individual tests. These method +names must start with the word "test" and should have descriptive names for precisely what +they are testing: +`testUserCanModifyFile()` `testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()` + +Writing tests is an art, and there are many resources available to help learn how. +Review the links above and always pay attention to your code coverage. + +### Database Tests + +Tests can include migrating, seeding, and testing against a mock or live database. +Be sure to modify the test case (or create your own) to point to your seed and migrations +and include any additional steps to be run before tests in the `setUp()` method. +See [Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) +for details. diff --git a/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php new file mode 100644 index 0000000..a73356d --- /dev/null +++ b/tests/_support/Database/Migrations/2020-02-22-222222_example_migration.php @@ -0,0 +1,37 @@ +forge->addField('id'); + $this->forge->addField([ + 'name' => ['type' => 'varchar', 'constraint' => 31], + 'uid' => ['type' => 'varchar', 'constraint' => 31], + 'class' => ['type' => 'varchar', 'constraint' => 63], + 'icon' => ['type' => 'varchar', 'constraint' => 31], + 'summary' => ['type' => 'varchar', 'constraint' => 255], + 'created_at' => ['type' => 'datetime', 'null' => true], + 'updated_at' => ['type' => 'datetime', 'null' => true], + 'deleted_at' => ['type' => 'datetime', 'null' => true], + ]); + + $this->forge->addKey('name'); + $this->forge->addKey('uid'); + $this->forge->addKey(['deleted_at', 'id']); + $this->forge->addKey('created_at'); + + $this->forge->createTable('factories'); + } + + public function down(): void + { + $this->forge->dropTable('factories'); + } +} diff --git a/tests/_support/Database/Seeds/ExampleSeeder.php b/tests/_support/Database/Seeds/ExampleSeeder.php new file mode 100644 index 0000000..619fc27 --- /dev/null +++ b/tests/_support/Database/Seeds/ExampleSeeder.php @@ -0,0 +1,41 @@ + 'Test Factory', + 'uid' => 'test001', + 'class' => 'Factories\Tests\NewFactory', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Longer sample text for testing', + ], + [ + 'name' => 'Widget Factory', + 'uid' => 'widget', + 'class' => 'Factories\Tests\WidgetPlant', + 'icon' => 'fas fa-puzzle-piece', + 'summary' => 'Create widgets in your factory', + ], + [ + 'name' => 'Evil Factory', + 'uid' => 'evil-maker', + 'class' => 'Factories\Evil\MyFactory', + 'icon' => 'fas fa-book-dead', + 'summary' => 'Abandon all hope, ye who enter here', + ], + ]; + + $builder = $this->db->table('factories'); + + foreach ($factories as $factory) { + $builder->insert($factory); + } + } +} diff --git a/tests/_support/Libraries/ConfigReader.php b/tests/_support/Libraries/ConfigReader.php new file mode 100644 index 0000000..0bb4a8f --- /dev/null +++ b/tests/_support/Libraries/ConfigReader.php @@ -0,0 +1,19 @@ +findAll(); + + // Make sure the count is as expected + $this->assertCount(3, $objects); + } + + public function testSoftDeleteLeavesRow(): void + { + $model = new ExampleModel(); + $this->setPrivateProperty($model, 'useSoftDeletes', true); + $this->setPrivateProperty($model, 'tempUseSoftDeletes', true); + + /** @var stdClass $object */ + $object = $model->first(); + $model->delete($object->id); + + // The model should no longer find it + $this->assertNull($model->find($object->id)); + + // ... but it should still be in the database + $result = $model->builder()->where('id', $object->id)->get()->getResult(); + + $this->assertCount(1, $result); + } +} diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/tests/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/tests/session/ExampleSessionTest.php b/tests/session/ExampleSessionTest.php new file mode 100644 index 0000000..33242a4 --- /dev/null +++ b/tests/session/ExampleSessionTest.php @@ -0,0 +1,17 @@ +set('logged_in', 123); + $this->assertSame(123, $session->get('logged_in')); + } +} diff --git a/tests/unit/HealthTest.php b/tests/unit/HealthTest.php new file mode 100644 index 0000000..1df24df --- /dev/null +++ b/tests/unit/HealthTest.php @@ -0,0 +1,49 @@ +assertTrue(defined('APPPATH')); + } + + public function testBaseUrlHasBeenSet(): void + { + $validation = service('validation'); + + $env = false; + + // Check the baseURL in .env + if (is_file(HOMEPATH . '.env')) { + $env = preg_grep('/^app\.baseURL = ./', file(HOMEPATH . '.env')) !== false; + } + + if ($env) { + // BaseURL in .env is a valid URL? + // phpunit.dist.xml sets app.baseURL in $_SERVER + // So if you set app.baseURL in .env, it takes precedence + $config = new App(); + $this->assertTrue( + $validation->check($config->baseURL, 'valid_url'), + 'baseURL "' . $config->baseURL . '" in .env is not valid URL', + ); + } + + // Get the baseURL in app/Config/App.php + // You can't use Config\App, because phpunit.dist.xml sets app.baseURL + $reader = new ConfigReader(); + + // BaseURL in app/Config/App.php is a valid URL? + $this->assertTrue( + $validation->check($reader->baseURL, 'valid_url'), + 'baseURL "' . $reader->baseURL . '" in app/Config/App.php is not valid URL', + ); + } +} diff --git a/writable/.htaccess b/writable/.htaccess new file mode 100644 index 0000000..3462048 --- /dev/null +++ b/writable/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/writable/debugbar/index.html b/writable/debugbar/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/debugbar/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/index.html b/writable/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/logs/index.html b/writable/logs/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/logs/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/session/index.html b/writable/session/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/session/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + diff --git a/writable/uploads/index.html b/writable/uploads/index.html new file mode 100644 index 0000000..b702fbc --- /dev/null +++ b/writable/uploads/index.html @@ -0,0 +1,11 @@ + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + +