Files
fristydev/components/game-profile-modal.tsx
T
fristy cbbbc3effa
Build & Push / Lint & Build (push) Successful in 1m32s
Build & Push / Build & Push Image (push) Successful in 48s
Build & Push / Trigger Deploy (push) Successful in 2s
Game profile: IGDB platforms, IGDB link, sparse-profile fix
- Source platforms from IGDB first, fall back to Steam and
  detectables only when IGDB has none
- Add IGDB page button next to Steam, SteamDB, Discord and ITAD
- Hide the details box when a profile has no details instead
  of rendering it empty
- ITAD lookup matches titles ignoring punctuation and marks
2026-09-11 06:21:54 +03:00

605 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
import { steamdbRating } from "@/lib/game-profile";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
type ModalDict = Dictionary["discord"]["gameModal"];
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
"Overwhelmingly Positive": "overwhelminglyPositive",
"Very Positive": "veryPositive",
Positive: "positive",
"Mostly Positive": "mostlyPositive",
Mixed: "mixed",
"Mostly Negative": "mostlyNegative",
Negative: "negative",
"Very Negative": "veryNegative",
"Overwhelmingly Negative": "overwhelminglyNegative",
};
const LOCALE_TAGS: Record<string, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
const COUNTRIES: Record<string, string> = {
tr: "TR",
en: "US",
de: "DE",
};
function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
function formatRatingPercent(value: number, tag: string): string {
try {
return new Intl.NumberFormat(tag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
function formatPrice(point: GamePricePoint, tag: string): string {
try {
return new Intl.NumberFormat(tag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
function formatReleaseDate(value: string, tag: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString(tag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(tag)} ${unit}`;
}
function discountPercent(point: GamePricePoint): number | null {
if (point.regular > point.price && point.regular > 0) {
return Math.round((1 - point.price / point.regular) * 100);
}
return null;
}
export function GameProfileModal({
applicationId,
heroSeed,
lang,
iconUrl,
fallbackName,
fallbackGameUrl,
dict,
onClose,
}: {
applicationId: string;
heroSeed: number;
lang: string;
iconUrl: string | null;
fallbackName: string;
fallbackGameUrl: string;
dict: ModalDict;
onClose: () => void;
}) {
const [profile, setProfile] = useState<GameProfile | null>(null);
const [failed, setFailed] = useState(false);
const [bannerPick, setBannerPick] = useState<string | null>(null);
const tag = LOCALE_TAGS[lang] ?? "en-US";
const country = COUNTRIES[lang] ?? "US";
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const response = await fetch(
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
);
if (!response.ok) {
throw new Error(`game api: ${response.status}`);
}
const json = (await response.json()) as GameProfile;
if (!cancelled) {
setProfile(json);
}
} catch {
if (!cancelled) {
setFailed(true);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [applicationId, lang, country]);
useEffect(() => {
function onKey(event: KeyboardEvent): void {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", onKey);
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previous;
};
}, [onClose]);
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
const name = profile?.name ?? fallbackName;
const best = profile?.itad?.prices ?? null;
const low = profile?.itad?.historyLow ?? null;
const hero = useMemo(() => {
if (bannerPick) {
return bannerPick;
}
const shots = profile?.screenshots ?? [];
if (shots.length > 0) {
return shots[heroSeed % shots.length] ?? null;
}
return profile?.artworkUrl ?? null;
}, [profile, heroSeed, bannerPick]);
const dbRating =
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
: null;
const itadReviews = profile?.itad?.reviews ?? [];
const hasDetails = !!(
profile &&
(profile.developers.length > 0 ||
profile.publishers.length > 0 ||
profile.releaseDate ||
profile.platforms.length > 0 ||
profile.links.length > 0)
);
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
const hasReviews = !!(
profile?.steamReviews ||
(dbRating != null && profile?.steamId) ||
metascore ||
opencritic
);
const stripRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const strip = stripRef.current;
if (!strip) {
return;
}
function onWheel(event: WheelEvent): void {
const el = stripRef.current;
if (!el) {
return;
}
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
return;
}
if (el.scrollWidth <= el.clientWidth + 1) {
return;
}
event.preventDefault();
el.scrollLeft += event.deltaY;
}
strip.addEventListener("wheel", onWheel, { passive: false });
return () => strip.removeEventListener("wheel", onWheel);
}, [profile]);
function scrollStrip(by: number): void {
stripRef.current?.scrollBy({ left: by, behavior: "smooth" });
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-4 backdrop-blur-sm sm:items-center"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={name}
className="modal-scroll max-h-[85vh] w-full max-w-4xl overflow-y-auto rounded-3xl border border-white/10 bg-panel shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
{hero ? (
<div className="relative h-48 w-full shrink-0 sm:h-64">
<Image
src={hero}
alt=""
fill
sizes="(max-width: 896px) 100vw, 896px"
className="rounded-t-3xl object-cover"
priority
/>
<div className="absolute inset-0 rounded-t-3xl bg-gradient-to-t from-panel via-panel/30 to-transparent" />
</div>
) : null}
<div className="p-5 sm:p-6">
<div className="flex items-center gap-4">
{iconUrl ? (
<Image
src={iconUrl}
alt=""
width={64}
height={64}
className="shrink-0 rounded-2xl border border-white/10"
/>
) : (
<div
aria-hidden
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blurple/20 text-2xl font-bold text-blurple-soft"
>
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-bold">{name}</h2>
{profile && profile.genres.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{profile.genres.map((genre) => (
<span
key={genre}
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-zinc-300"
>
{genre}
</span>
))}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
{dict.close}
</button>
</div>
{!profile && !failed ? (
<p className="mt-4 animate-pulse text-sm text-zinc-500">{dict.loading}</p>
) : null}
{failed && !profile ? (
<p className="mt-4 text-sm text-zinc-500">{dict.loadError}</p>
) : null}
{profile?.description ? (
<p className="mt-4 text-sm leading-relaxed text-zinc-300">
{profile.description}
</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{profile?.steamUrl ? (
<a
href={profile.steamUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-blurple px-4 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
>
{dict.openOnSteam} ↗
</a>
) : null}
{profile?.steamId ? (
<a
href={`https://steamdb.info/app/${profile.steamId}/`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.openOnSteamDb} ↗
</a>
) : null}
{profile?.igdbUrl ? (
<a
href={profile.igdbUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.openOnIgdb} ↗
</a>
) : null}
<a
href={gameUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.openOnDiscord} ↗
</a>
{profile?.itad?.pageUrl ? (
<a
href={profile.itad.pageUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.allPrices} ↗
</a>
) : null}
</div>
{hasDetails ? (
<dl className="mt-5 grid gap-x-6 gap-y-2 rounded-2xl border border-white/5 bg-white/[0.03] p-4 text-sm md:grid-cols-2">
{profile.developers.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.developers}
</dt>
<dd className="text-zinc-200">
{profile.developers.join(", ")}
</dd>
</div>
) : null}
{profile.publishers.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.publishers}
</dt>
<dd className="text-zinc-200">
{profile.publishers.join(", ")}
</dd>
</div>
) : null}
{profile.releaseDate ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.releaseDate}
</dt>
<dd className="text-zinc-200">
{formatReleaseDate(profile.releaseDate, tag)}
</dd>
</div>
) : null}
{profile.platforms.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.platforms}
</dt>
<dd className="text-zinc-200">
{profile.platforms.join(" · ")}
</dd>
</div>
) : null}
{profile.links.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3 md:col-span-2">
<dt className="shrink-0 text-zinc-500 sm:w-36">{dict.links}</dt>
<dd className="flex flex-wrap gap-x-3 gap-y-1">
{profile.links.slice(0, 8).map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blurple-soft transition-colors hover:text-white"
>
{link.label} ↗
</a>
))}
</dd>
</div>
) : null}
</dl>
) : null}
{profile && hasReviews ? (
<div className="mt-4">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.reviews}
</p>
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
{profile.steamReviews ? (
<a
href={profile.steamReviews.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
{dict.totalReviews}
</p>
<p className="mt-1 text-sm font-semibold">
{localizeScore(profile.steamReviews.label, dict)}
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(
profile.steamReviews.total,
tag,
dict.reviewsUnit,
)}
</p>
</a>
) : null}
{dbRating != null && profile.steamId ? (
<a
href={`https://steamdb.info/app/${profile.steamId}/`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
{dict.steamdbRating}
</p>
<p className="mt-1 text-sm font-semibold">
{formatRatingPercent(dbRating, tag)}
</p>
<p className="font-mono text-xs text-zinc-500">
steamdb.info ↗
</p>
</a>
) : null}
{metascore ? (
<a
href={metascore.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
Metacritic
</p>
<p className="mt-1 text-sm font-semibold">
{metascore.score ?? "—"}
<span className="font-normal text-zinc-500">/100</span>
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(metascore.count, tag, dict.reviewsUnit)}
</p>
</a>
) : null}
{opencritic ? (
<a
href={opencritic.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
OpenCritic
</p>
<p className="mt-1 text-sm font-semibold">
{opencritic.score ?? "—"}
<span className="font-normal text-zinc-500">/100</span>
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
</p>
</a>
) : null}
</div>
</div>
) : null}
{profile?.itad && (best || low) ? (
<div className="mt-4">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.prices}
</p>
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
{best ? (
<a
href={best.url || undefined}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
>
<p className="font-mono text-xs text-mint">
{dict.bestPrice} · {best.shop}
</p>
<p className="mt-1 text-lg font-bold">
{formatPrice(best, tag)}{" "}
{discountPercent(best) ? (
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
-%{discountPercent(best)}
</span>
) : null}
</p>
</a>
) : null}
{low ? (
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
<p className="font-mono text-xs text-zinc-500">
{dict.historyLow} · {low.shop}
</p>
<p className="mt-1 text-lg font-bold text-zinc-200">
{formatPrice(low, tag)}
</p>
</div>
) : null}
</div>
</div>
) : null}
{profile && profile.screenshots.length > 0 ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.screenshots}
</p>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => scrollStrip(-360)}
aria-label={dict.scrollBack}
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
‹
</button>
<button
type="button"
onClick={() => scrollStrip(360)}
aria-label={dict.scrollForward}
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
›
</button>
</div>
</div>
<div
ref={stripRef}
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
>
{profile.screenshots.map((shot) => (
<button
key={shot}
type="button"
onClick={() => setBannerPick(shot)}
aria-label={dict.screenshots}
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
hero === shot
? "ring-2 ring-blurple"
: "opacity-70 hover:opacity-100"
}`}
>
<Image
src={shot}
alt=""
width={320}
height={180}
loading="lazy"
className="h-auto w-56"
/>
</button>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
);
}