Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect } from "react";
|
|
import { ModalPrices, ModalReviews, ModalScreenshots } from "@/components/game-profile-sections";
|
|
import { formatReleaseDate, selectGameView, type ModalDict } from "@/lib/game/game-format";
|
|
import { steamdbAppUrl } from "@/lib/game/discord-app";
|
|
import type { Locale } from "@/lib/i18n/locales";
|
|
import { useGameProfile } from "@/hooks/use-game-profile";
|
|
|
|
export function GameProfileModal({
|
|
applicationId,
|
|
heroSeed,
|
|
lang,
|
|
iconUrl,
|
|
fallbackName,
|
|
fallbackGameUrl,
|
|
dict,
|
|
onClose,
|
|
}: {
|
|
applicationId: string;
|
|
heroSeed: number;
|
|
lang: Locale;
|
|
iconUrl: string | null;
|
|
fallbackName: string;
|
|
fallbackGameUrl: string;
|
|
dict: ModalDict;
|
|
onClose: () => void;
|
|
}) {
|
|
const {
|
|
profile,
|
|
failed,
|
|
localeTag,
|
|
hero,
|
|
hasDetails,
|
|
partialFail,
|
|
isDeterministicMiss,
|
|
canRetry,
|
|
retry,
|
|
reset,
|
|
setBannerPick,
|
|
} = useGameProfile(applicationId, lang, heroSeed);
|
|
|
|
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, name, bestPrice, historyLow, isFree } = selectGameView(
|
|
profile,
|
|
fallbackName,
|
|
fallbackGameUrl,
|
|
);
|
|
|
|
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 ? (
|
|
<div className="mt-4 flex items-center gap-3 text-sm text-zinc-500">
|
|
<p>{dict.loadError}</p>
|
|
<button
|
|
type="button"
|
|
onClick={retry}
|
|
disabled={!canRetry}
|
|
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{dict.retry}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{isDeterministicMiss ? (
|
|
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
|
|
<p className="flex-1">{dict.noGameData}</p>
|
|
</div>
|
|
) : partialFail ? (
|
|
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
|
|
<p className="flex-1">{dict.partialFail}</p>
|
|
<button
|
|
type="button"
|
|
onClick={reset}
|
|
disabled={!canRetry}
|
|
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{dict.retry}
|
|
</button>
|
|
</div>
|
|
) : 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={steamdbAppUrl(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>
|
|
|
|
{profile && 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, localeTag)}
|
|
</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 ? <ModalReviews profile={profile} dict={dict} localeTag={localeTag} /> : null}
|
|
|
|
{profile?.itad && (bestPrice || historyLow) ? (
|
|
<ModalPrices
|
|
dict={dict}
|
|
localeTag={localeTag}
|
|
bestPrice={bestPrice}
|
|
historyLow={historyLow}
|
|
isFree={isFree}
|
|
/>
|
|
) : null}
|
|
|
|
{profile && profile.screenshots.length > 0 ? (
|
|
<ModalScreenshots
|
|
profile={profile}
|
|
dict={dict}
|
|
hero={hero}
|
|
onPick={setBannerPick}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|