Game profile: granular IGDB platforms and free-game badge
Build & Push / Lint & Build (push) Successful in 1m16s
Build & Push / Build & Push Image (push) Successful in 28s
Build & Push / Trigger Deploy (push) Successful in 2s

- List IGDB platform names verbatim (Windows, MacOS) instead of
  coarse buckets; fall back to Steam OS flags and hardware
  distributors only when IGDB has none
- Show a Free badge instead of prices when best price is zero
This commit is contained in:
fristy committed 2026-09-11 06:51:41 +03:00
1 parent d778a18a49
commit 7d6d31265b
5 files changed
+74 -54

No files matched your search

+39 -32
View File
@@ -161,6 +161,7 @@ export function GameProfileModal({
const name = profile?.name ?? fallbackName; const name = profile?.name ?? fallbackName;
const best = profile?.itad?.prices ?? null; const best = profile?.itad?.prices ?? null;
const low = profile?.itad?.historyLow ?? null; const low = profile?.itad?.historyLow ?? null;
const isFree = !!best && best.price === 0;
const hero = useMemo(() => { const hero = useMemo(() => {
if (bannerPick) { if (bannerPick) {
return bannerPick; return bannerPick;
@@ -539,38 +540,44 @@ export function GameProfileModal({
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500"> <p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.prices} {dict.prices}
</p> </p>
<div className="mt-1.5 grid gap-2 sm:grid-cols-2"> {isFree ? (
{best ? ( <p className="mt-1.5 inline-block rounded-full border border-mint/20 bg-mint/5 px-4 py-1.5 text-xs font-semibold text-mint">
<a {dict.free}
href={best.url || undefined} </p>
target="_blank" ) : (
rel="noopener noreferrer" <div className="mt-1.5 grid gap-2 sm:grid-cols-2">
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40" {best ? (
> <a
<p className="font-mono text-xs text-mint"> href={best.url || undefined}
{dict.bestPrice} · {best.shop} target="_blank"
</p> rel="noopener noreferrer"
<p className="mt-1 text-lg font-bold"> className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
{formatPrice(best, tag)}{" "} >
{discountPercent(best) ? ( <p className="font-mono text-xs text-mint">
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint"> {dict.bestPrice} · {best.shop}
-%{discountPercent(best)} </p>
</span> <p className="mt-1 text-lg font-bold">
) : null} {formatPrice(best, tag)}{" "}
</p> {discountPercent(best) ? (
</a> <span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
) : null} -%{discountPercent(best)}
{low ? ( </span>
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3"> ) : null}
<p className="font-mono text-xs text-zinc-500"> </p>
{dict.historyLow} · {low.shop} </a>
</p> ) : null}
<p className="mt-1 text-lg font-bold text-zinc-200"> {low ? (
{formatPrice(low, tag)} <div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
</p> <p className="font-mono text-xs text-zinc-500">
</div> {dict.historyLow} · {low.shop}
) : null} </p>
</div> <p className="mt-1 text-lg font-bold text-zinc-200">
{formatPrice(low, tag)}
</p>
</div>
) : null}
</div>
)}
</div> </div>
) : null} ) : null}
+32 -22
View File
@@ -88,9 +88,7 @@ async function getDetectables(): Promise<DetectableEntry[]> {
return detectablesCache.data; return detectablesCache.data;
} }
try { try {
const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json", { const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json");
next: { revalidate: 86_400 },
});
if (!response.ok) { if (!response.ok) {
console.error(`detectables failed: ${response.status}`); console.error(`detectables failed: ${response.status}`);
return detectablesCache?.data ?? []; return detectablesCache?.data ?? [];
@@ -104,10 +102,6 @@ async function getDetectables(): Promise<DetectableEntry[]> {
} }
const DISTRIBUTOR_PLATFORM: Record<string, string> = { const DISTRIBUTOR_PLATFORM: Record<string, string> = {
steam: "PC",
battlenet: "PC",
gog: "PC",
epic: "PC",
xbox: "Xbox", xbox: "Xbox",
xbox_title: "Xbox", xbox_title: "Xbox",
xbox_game_pass: "Xbox", xbox_game_pass: "Xbox",
@@ -220,10 +214,16 @@ interface IgdbGame {
let igdbTokenCache: { token: string; expires: number } | null = null; let igdbTokenCache: { token: string; expires: number } | null = null;
let igdbKeyWarned = false;
async function getIgdbToken(): Promise<string | null> { async function getIgdbToken(): Promise<string | null> {
const clientId = process.env.TWITCH_CLIENT_ID; const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET; const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
if (!igdbKeyWarned) {
igdbKeyWarned = true;
console.error("igdb keys missing: set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET");
}
return null; return null;
} }
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
@@ -386,9 +386,15 @@ interface ItadOverviewPrice {
} | null; } | null;
} }
let itadKeyWarned = false;
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> { async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
const key = process.env.ITAD_API_KEY; const key = process.env.ITAD_API_KEY;
if (!key) { if (!key) {
if (!itadKeyWarned) {
itadKeyWarned = true;
console.error("itad key missing: set ITAD_API_KEY");
}
return null; return null;
} }
try { try {
@@ -499,24 +505,28 @@ export async function getGameProfile(
const igdbPlatforms = new Set<string>(); const igdbPlatforms = new Set<string>();
for (const p of igdb?.platforms ?? []) { for (const p of igdb?.platforms ?? []) {
const label = `${p.abbreviation ?? ""} ${p.name ?? ""}`.toUpperCase(); const raw = (p.name ?? "").trim();
if (/PS|PLAYSTATION/.test(label)) { if (!raw) {
igdbPlatforms.add("PlayStation"); continue;
} else if (/XBOX|XONE|XSX/.test(label)) { }
igdbPlatforms.add("Xbox"); if (raw === "PC (Microsoft Windows)") {
} else if (/PC|WIN|MAC|LIN/.test(label)) { igdbPlatforms.add("Windows");
igdbPlatforms.add("PC"); } else if (raw === "Mac") {
} else if (/SWITCH/.test(label)) { igdbPlatforms.add("MacOS");
igdbPlatforms.add("Switch"); } else {
igdbPlatforms.add(raw);
} }
} }
const platforms = new Set<string>(igdbPlatforms); const platforms = new Set<string>(igdbPlatforms);
if (platforms.size === 0) { if (platforms.size === 0 && steam?.platforms) {
if ( if (steam.platforms.windows) {
steam?.platforms && platforms.add("Windows");
(steam.platforms.windows || steam.platforms.mac || steam.platforms.linux) }
) { if (steam.platforms.mac) {
platforms.add("PC"); platforms.add("MacOS");
}
if (steam.platforms.linux) {
platforms.add("Linux");
} }
for (const sku of entry?.third_party_skus ?? []) { for (const sku of entry?.third_party_skus ?? []) {
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor]; const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
+1
View File
@@ -37,6 +37,7 @@
"links": "Links", "links": "Links",
"reviews": "Bewertungen", "reviews": "Bewertungen",
"prices": "Preise", "prices": "Preise",
"free": "Kostenlos",
"bestPrice": "Bester Preis", "bestPrice": "Bester Preis",
"historyLow": "Historischer Tiefstpreis", "historyLow": "Historischer Tiefstpreis",
"allPrices": "ITAD", "allPrices": "ITAD",
+1
View File
@@ -37,6 +37,7 @@
"links": "Links", "links": "Links",
"reviews": "Reviews", "reviews": "Reviews",
"prices": "Prices", "prices": "Prices",
"free": "Free",
"bestPrice": "Best Price", "bestPrice": "Best Price",
"historyLow": "All-Time Low", "historyLow": "All-Time Low",
"allPrices": "ITAD", "allPrices": "ITAD",
+1
View File
@@ -37,6 +37,7 @@
"links": "Bağlantılar", "links": "Bağlantılar",
"reviews": "İncelemeler", "reviews": "İncelemeler",
"prices": "Fiyatlar", "prices": "Fiyatlar",
"free": "Ücretsiz",
"bestPrice": "En İyi Fiyat", "bestPrice": "En İyi Fiyat",
"historyLow": "Tarihi En Düşük", "historyLow": "Tarihi En Düşük",
"allPrices": "ITAD", "allPrices": "ITAD",