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.
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import { useEffect, useRef } from "react";
|
||
import type { GamePricePoint, GameProfile } from "@/lib/game/game-profile-types";
|
||
import {
|
||
describeProfile,
|
||
discountPercent,
|
||
formatCount,
|
||
formatPrice,
|
||
formatRatingPercent,
|
||
localizeScore,
|
||
type ModalDict,
|
||
} from "@/lib/game/game-format";
|
||
import { steamdbAppUrl } from "@/lib/game/discord-app";
|
||
|
||
export function ModalReviews({
|
||
profile,
|
||
dict,
|
||
localeTag,
|
||
}: {
|
||
profile: GameProfile;
|
||
dict: ModalDict;
|
||
localeTag: string;
|
||
}) {
|
||
const { dbRating, hasReviews, metascore, opencritic } = describeProfile(profile);
|
||
if (!hasReviews) {
|
||
return null;
|
||
}
|
||
const steamdbRating: { score: number; url: string } | null =
|
||
dbRating != null && profile.steamId
|
||
? { score: dbRating, url: steamdbAppUrl(profile.steamId) }
|
||
: null;
|
||
return (
|
||
<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, localeTag, dict.reviewsUnit)}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{steamdbRating ? (
|
||
<a
|
||
href={steamdbRating.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.steamdbRating}</p>
|
||
<p className="mt-1 text-sm font-semibold">
|
||
{formatRatingPercent(steamdbRating.score, localeTag)}
|
||
</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 ?? "—"}
|
||
{metascore.score != null ? (
|
||
<span className="font-normal text-zinc-500">/100</span>
|
||
) : null}
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{formatCount(metascore.count, localeTag, 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 ?? "—"}
|
||
{opencritic.score != null ? (
|
||
<span className="font-normal text-zinc-500">/100</span>
|
||
) : null}
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{formatCount(opencritic.count, localeTag, dict.reviewsUnit)}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ModalPrices({
|
||
dict,
|
||
localeTag,
|
||
bestPrice,
|
||
historyLow,
|
||
isFree,
|
||
}: {
|
||
dict: ModalDict;
|
||
localeTag: string;
|
||
bestPrice: GamePricePoint | null;
|
||
historyLow: GamePricePoint | null;
|
||
isFree: boolean;
|
||
}) {
|
||
return (
|
||
<div className="mt-4">
|
||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||
{dict.prices}
|
||
</p>
|
||
{isFree ? (
|
||
<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">
|
||
{dict.free}
|
||
</p>
|
||
) : (
|
||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||
{bestPrice ? (
|
||
<a
|
||
href={bestPrice.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} · {bestPrice.shop}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold">
|
||
{formatPrice(bestPrice, localeTag)}{" "}
|
||
{discountPercent(bestPrice) ? (
|
||
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
|
||
-%{discountPercent(bestPrice)}
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{historyLow ? (
|
||
<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} · {historyLow.shop}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold text-zinc-200">
|
||
{formatPrice(historyLow, localeTag)}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ModalScreenshots({
|
||
profile,
|
||
dict,
|
||
hero,
|
||
onPick,
|
||
}: {
|
||
profile: GameProfile;
|
||
dict: ModalDict;
|
||
hero: string | null;
|
||
onPick: (shot: string) => void;
|
||
}) {
|
||
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="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={() => onPick(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>
|
||
);
|
||
}
|