Files
fristydev/components/game-profile-sections.tsx
fristy 864b9226fc
Build & Push / Lint & Build (pull_request) Failing after 52s
Build & Push / Build & Push Image (pull_request) Skipped
Build & Push / Trigger Deploy (pull_request) Skipped
desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
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.
2026-09-11 23:54:59 +03:00

263 lines
10 KiB
TypeScript
Raw Permalink 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, 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>
);
}