Merge pull request 'desloppify: code health overhaul plus abuse hardening' (#3) from desloppify/code-health into main
CI / Lint, Test & Build (push) Successful in 1m25s
CI / Code Health (push) Successful in 16s
Deploy / Build & Push Image (push) Successful in 48s
Deploy / Trigger Deploy (push) Successful in 1s

Merge PR #3: code health overhaul plus abuse hardening, 140 vitest green, CI split preserved
This commit was merged in pull request #3.
This commit is contained in:
OpenCode committed 2026-09-12 00:58:23 +03:00
commit 0c30c18e24
79 files changed
+4913 -1426

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
run: |
pipx run --spec desloppify desloppify scan --profile ci --path . --no-badge
- name: Upload scorecard
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: desloppify-scorecard
path: scorecard.png
+3
View File
@@ -45,3 +45,6 @@ next-env.d.ts
# local code intelligence index
.codegraph/
# desloppify local state (scan state, review packets, score snapshots)
.desloppify/
+3
View File
@@ -1,5 +1,7 @@
# fristy.net
![Code Health](scorecard.png)
My personal corner of the internet — projects, live Discord status, and ways to reach me.
**Live:** https://fristy.net
@@ -29,6 +31,7 @@ pnpm dev # http://localhost:3000
| `pnpm dev` | Start the dev server |
| `pnpm build` | Production build |
| `pnpm start` | Serve the production build |
| `pnpm test` | Run the Vitest suite |
| `pnpm lint` | Run ESLint |
| `pnpm format` | Format everything with Prettier |
| `pnpm format:check` | Fail if anything is unformatted |
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/discord-app/[id]/route";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function contextWith(id: string) {
return { params: Promise.resolve({ id }) };
}
describe("GET /api/discord-app/[id]", () => {
it("returns 400 for non-numeric ids", async () => {
const response = await GET(
new Request("https://fristy.net/api/discord-app/abc"),
contextWith("abc"),
);
expect(response.status).toBe(400);
});
it("returns 404 when the app is unknown", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 404 })),
);
const response = await GET(
new Request("https://fristy.net/api/discord-app/123"),
contextWith("123"),
);
expect(response.status).toBe(404);
});
it("returns 502 when upstream networking fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
const response = await GET(
new Request("https://fristy.net/api/discord-app/123"),
contextWith("123"),
);
expect(response.status).toBe(502);
});
});
+28 -31
View File
@@ -1,37 +1,34 @@
import { NextResponse } from "next/server";
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors";
import { getDiscordAppInfo } from "@/lib/lanyard";
import { getDiscordAppInfo } from "@/lib/game/discord-app";
import {
invalidIdResponse,
jsonErrorWithCors,
jsonWithCors,
parseAppId,
requestOrigin,
} from "@/lib/api-route";
import { warnDev } from "@/lib/log";
import type { NextResponse } from "next/server";
export async function OPTIONS(request: Request) {
if (!isAllowedOrigin(request)) {
return forbidden();
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
const origin = requestOrigin(request);
const id = await parseAppId(context);
if (!id) {
return invalidIdResponse(origin);
}
return new NextResponse(null, {
status: 204,
headers: {
...corsHeaders(),
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "86400",
},
});
let info;
try {
info = await getDiscordAppInfo(id);
} catch (error) {
warnDev(
`discord-app upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`,
);
return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin);
}
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
if (!isAllowedOrigin(request)) {
return forbidden();
}
const { id } = await context.params;
if (!/^\d+$/.test(id)) {
return NextResponse.json({ error: "Invalid application id" }, { status: 400 });
}
const info = await getDiscordAppInfo(id);
if (!info) {
return NextResponse.json({ error: "Application not found" }, { status: 404 });
return jsonErrorWithCors({ error: "Application not found" }, 404, 60, origin);
}
return NextResponse.json(info, {
headers: {
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400",
...corsHeaders(),
},
});
return jsonWithCors(info, 86400, origin);
}
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/game/[id]/route";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function contextWith(id: string) {
return { params: Promise.resolve({ id }) };
}
function requestWith(url: string) {
return new Request(url);
}
describe("GET /api/game/[id]", () => {
it("returns 400 for non-numeric ids", async () => {
const response = await GET(
requestWith("https://fristy.net/api/game/abc"),
contextWith("abc"),
);
expect(response.status).toBe(400);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
it("returns 404 when no profile resolves", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 404 })),
);
const response = await GET(
requestWith("https://fristy.net/api/game/123"),
contextWith("123"),
);
expect(response.status).toBe(404);
});
it("returns 502 when upstream networking fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
const response = await GET(
requestWith("https://fristy.net/api/game/123"),
contextWith("123"),
);
expect(response.status).toBe(502);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
});
+31 -38
View File
@@ -1,44 +1,37 @@
import { NextResponse } from "next/server";
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors";
import { getGameProfile } from "@/lib/game-profile";
import {
invalidIdResponse,
jsonErrorWithCors,
jsonWithCors,
parseAppId,
requestOrigin,
} from "@/lib/api-route";
import { resolveGameLocale } from "@/lib/game/game-format";
import { getGameProfile } from "@/lib/game/game-profile";
import { warnDev } from "@/lib/log";
import type { NextResponse } from "next/server";
export async function OPTIONS(request: Request) {
if (!isAllowedOrigin(request)) {
return forbidden();
}
return new NextResponse(null, {
status: 204,
headers: {
...corsHeaders(),
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "86400",
},
});
}
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
if (!isAllowedOrigin(request)) {
return forbidden();
}
const { id } = await context.params;
if (!/^\d+$/.test(id)) {
return NextResponse.json({ error: "Invalid application id" }, { status: 400 });
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
const origin = requestOrigin(request);
const id = await parseAppId(context);
if (!id) {
return invalidIdResponse(origin);
}
const { searchParams } = new URL(request.url);
const country = /^[A-Z]{2}$/.test(searchParams.get("country") ?? "")
? (searchParams.get("country") as string)
: "TR";
const lang = ["tr", "en", "de"].includes(searchParams.get("lang") ?? "")
? (searchParams.get("lang") as string)
: "en";
const profile = await getGameProfile(id, country, lang);
const countryRaw = searchParams.get("country") ?? "";
const country = /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : "TR";
const { lang } = resolveGameLocale(searchParams.get("lang") ?? "");
let profile;
try {
profile = await getGameProfile(id, lang, country);
} catch (error) {
warnDev(`game upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`);
return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin);
}
if (!profile) {
return NextResponse.json({ error: "Game not found" }, { status: 404 });
return jsonErrorWithCors({ error: "Game not found" }, 404, 60, origin);
}
return NextResponse.json(profile, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
...corsHeaders(),
},
});
return jsonWithCors(profile, 3600, origin);
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import ErrorPage from "@/app/error";
describe("Error page", () => {
it("renders and retries on click", () => {
const reset = vi.fn();
render(<ErrorPage error={new Error("boom")} reset={reset} />);
expect(screen.getByText("// 500")).toBeDefined();
fireEvent.click(screen.getByRole("button"));
expect(reset).toHaveBeenCalledOnce();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("next/font/google", () => ({
Cascadia_Mono: () => ({ variable: "font-test" }),
}));
vi.mock("@/lib/i18n/get-locale", () => ({
getLocale: async () => "en" as const,
}));
import RootLayout, { generateMetadata } from "@/app/layout";
describe("generateMetadata", () => {
it("builds site metadata for the locale", async () => {
const meta = await generateMetadata();
expect(meta.description).toBeTruthy();
});
});
describe("RootLayout", () => {
it("renders children inside the locale shell", async () => {
render(await RootLayout({ children: <p>child</p>, params: Promise.resolve({}) }));
expect(screen.getByText("child")).toBeDefined();
expect(document.documentElement.lang).toBe("en");
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Loading from "@/app/loading";
describe("Loading", () => {
it("renders a busy skeleton", () => {
const { container } = render(<Loading />);
expect(screen.getByRole("generic", { busy: true })).toBeDefined();
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import NotFound from "@/app/not-found";
vi.mock("@/lib/i18n/get-locale", () => ({
getLocale: async () => "en" as const,
}));
describe("NotFound page", () => {
it("renders the 404 block with a home link", async () => {
render(await NotFound());
expect(screen.getByText("404")).toBeDefined();
const home = screen.getByRole("link", { name: /home/i });
expect(home.getAttribute("href")).toBe("/");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Home from "@/app/page";
vi.mock("@/lib/i18n/get-locale", () => ({
getLocale: async () => "en" as const,
}));
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Home page", () => {
it("renders hero, sections, and footer", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (String(url).includes("git.fristy.net")) {
return { ok: true, json: async () => [] } as Response;
}
return {
ok: true,
json: async () => ({
success: true,
data: {
discord_user: {
id: "1",
username: "testuser",
global_name: "Test User",
display_name: "Test User",
discriminator: "0",
avatar: null,
bot: false,
},
activities: [],
discord_status: "offline",
active_on_discord_desktop: false,
active_on_discord_mobile: false,
active_on_discord_web: false,
listening_to_spotify: false,
spotify: null,
},
}),
} as Response;
}),
);
render(await Home());
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("fristy");
expect(screen.getByText("@testuser")).toBeDefined();
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { SiteHeader } from "@/components/site-header";
import { getRepos } from "@/lib/gitea";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getLocale } from "@/lib/i18n/get-locale";
import { DISCORD_ID, discordProfileUrl, getLanyard } from "@/lib/lanyard";
import { DISCORD_ID, discordProfileUrl, getLanyard } from "@/lib/game/lanyard";
export default async function Home() {
const locale = await getLocale();
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { ContactSection } from "@/components/contact-section";
import { getDictionary } from "@/lib/i18n/get-dictionary";
describe("ContactSection", () => {
it("renders contact channels", () => {
const dict = getDictionary("en");
render(<ContactSection dict={dict.contact} />);
expect(screen.getByText(dict.contact.title)).toBeDefined();
cleanup();
});
});
+2 -2
View File
@@ -8,9 +8,9 @@ import {
SPOTIFY_URL,
STEAM_URL,
} from "@/lib/site";
import { CopyEmail } from "./copy-email";
import { SectionHeading } from "./section-heading";
import { CopyEmail } from "@/components/copy-email";
import { SectionHeading } from "@/components/section-heading";
export function ContactSection({ dict }: { dict: Dictionary["contact"] }) {
const entries = [
{ label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" },
+22
View File
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { CopyEmail } from "@/components/copy-email";
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("CopyEmail", () => {
it("copies on click and shows feedback", async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
render(<CopyEmail email="a@b.c" copyLabel="Copy" copiedLabel="Copied" />);
fireEvent.click(screen.getByRole("button"));
expect(await screen.findByText("Copied")).toBeDefined();
expect(writeText).toHaveBeenCalledWith("a@b.c");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { DiscordCard } from "@/components/discord-card";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { lanyardFixture } from "@/test/fixtures";
describe("DiscordCard", () => {
it("renders presence with quiet state", () => {
const dict = getDictionary("en");
render(<DiscordCard initial={lanyardFixture()} dict={dict.discord} locale="en" />);
expect(screen.getByText("Test User")).toBeDefined();
expect(screen.getByText("@testuser")).toBeDefined();
expect(screen.getByText(dict.discord.noActivity)).toBeDefined();
});
it("renders the offline fallback without data", () => {
const dict = getDictionary("en");
render(<DiscordCard initial={null} dict={dict.discord} locale="en" />);
expect(screen.getByText(dict.discord.offline)).toBeDefined();
});
});
+42 -210
View File
@@ -4,20 +4,20 @@ import Image from "next/image";
import { useEffect, useState } from "react";
import { GameProfileModal } from "@/components/game-profile-modal";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import { useLanyard } from "@/hooks/use-lanyard";
import {
activityImageUrl,
DISCORD_ID,
discordAvatarUrl,
discordGameUrl,
formatSpotifyList,
LANYARD_WS,
lanyardUrl,
spotifyTrackUrl,
type DiscordAppInfo,
type DiscordStatus,
type LanyardActivity,
type LanyardData,
} from "@/lib/lanyard";
} from "@/lib/game/lanyard";
import { discordGameUrl, type DiscordAppInfo } from "@/lib/game/discord-app";
import { fetchJson } from "@/lib/client-fetch";
import { formatElapsed } from "@/lib/game/game-format";
import type { Locale } from "@/lib/i18n/locales";
const STATUS_DOT: Record<DiscordStatus, string> = {
online: "bg-emerald-400",
@@ -26,15 +26,13 @@ const STATUS_DOT: Record<DiscordStatus, string> = {
offline: "bg-zinc-500",
};
const WS_OPEN_TIMEOUT_MS = 10_000;
const RECONNECT_BASE_MS = 2_000;
const RECONNECT_MAX_MS = 30_000;
const SAFETY_POLL_MS = 60_000;
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
function formatTrackTime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const totalSeconds = Math.max(0, Math.floor(ms / MS_PER_SECOND));
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
@@ -47,11 +45,12 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) {
}, []);
const total = Math.max(1, end - start);
const elapsed = Math.min(Math.max(0, now - start), total);
const ratio = Math.min(1, Math.max(0, (now - start) / total));
const percent = Math.round(ratio * 100);
return (
<div className="mt-1.5 flex max-w-60 items-center gap-1.5 font-mono text-[10px] text-zinc-500">
<span>{formatTrackTime(now - start)}</span>
<span>{formatTrackTime(elapsed)}</span>
<div
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
role="progressbar"
@@ -66,43 +65,33 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) {
);
}
function formatElapsed(start: number, lang: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (lang === "tr") {
if (hours > 0) {
return `${hours}s ${minutes}dk`;
}
if (minutes > 0) {
return `${minutes}dk`;
}
return "<1dk";
}
if (lang === "de") {
if (hours > 0) {
return `${hours} Std. ${minutes} Min.`;
}
if (minutes > 0) {
return `${minutes} Min.`;
}
return "<1 Min.";
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return "<1m";
}
const DISCORD_APP_CACHE_LIMIT = 50;
const discordAppCache = new Map<string, DiscordAppInfo | null>();
export function _resetDiscordAppCache(): void {
discordAppCache.clear();
}
function cacheDiscordApp(id: string, info: DiscordAppInfo): void {
if (!discordAppCache.has(id) && discordAppCache.size >= DISCORD_APP_CACHE_LIMIT) {
const oldest = discordAppCache.keys().next();
if (!oldest.done) {
discordAppCache.delete(oldest.value);
}
}
discordAppCache.set(id, info);
}
function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null {
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
applicationId ? (discordAppCache.get(applicationId) ?? null) : null,
);
const [prevId, setPrevId] = useState<string | null | undefined>(applicationId);
if (prevId !== applicationId) {
setPrevId(applicationId);
setInfo(applicationId ? (discordAppCache.get(applicationId) ?? null) : null);
}
useEffect(() => {
if (!applicationId || discordAppCache.has(applicationId)) {
@@ -111,17 +100,13 @@ function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo
const id = applicationId;
let cancelled = false;
async function load(): Promise<void> {
try {
const response = await fetch(`/api/discord-app/${id}`);
const json = (await response.json()) as DiscordAppInfo | { error: string };
const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null;
discordAppCache.set(id, resolved);
const resolved = await fetchJson<DiscordAppInfo>(`/api/discord-app/${id}`);
if (resolved) {
cacheDiscordApp(id, resolved);
}
if (!cancelled) {
setInfo(resolved);
}
} catch {
discordAppCache.set(id, null);
}
}
void load();
return () => {
@@ -139,13 +124,13 @@ function ActivityRow({
}: {
activity: LanyardActivity;
dict: Dictionary["discord"];
locale: string;
locale: Locale;
}) {
const appInfo = useDiscordApp(activity.application_id);
const [profileOpen, setProfileOpen] = useState(false);
const [heroSeed, setHeroSeed] = useState(0);
const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null;
const small = activityImageUrl(activity, true);
const large = activityImageUrl(activity, "large") ?? appInfo?.iconUrl ?? null;
const small = activityImageUrl(activity, "small");
const gameUrl = activity.application_id
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
: null;
@@ -229,162 +214,9 @@ export function DiscordCard({
}: {
initial: LanyardData | null;
dict: Dictionary["discord"];
locale: string;
locale: Locale;
}) {
const [data, setData] = useState<LanyardData | null>(initial);
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let heartbeatTimer: number | undefined;
let reconnectTimer: number | undefined;
let reconnectDelay = RECONNECT_BASE_MS;
async function pollOnce(): Promise<void> {
try {
const response = await fetch(lanyardUrl(), { cache: "no-store" });
if (!response.ok) {
return;
}
const json = (await response.json()) as {
success: boolean;
data: LanyardData;
};
if (json.success && !disposed) {
setData(json.data);
}
} catch {
return;
}
}
function scheduleReconnect(): void {
if (disposed) {
return;
}
window.clearTimeout(reconnectTimer);
reconnectTimer = window.setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
connect();
}, reconnectDelay);
}
function dropSocket(current: WebSocket): void {
if (socket === current) {
socket = null;
}
try {
current.close();
} catch {
socket = null;
}
}
function connect(): void {
if (disposed) {
return;
}
let current: WebSocket;
try {
current = new WebSocket(LANYARD_WS);
} catch {
scheduleReconnect();
return;
}
socket = current;
let settled = false;
const openTimeout = window.setTimeout(() => {
if (!settled) {
settled = true;
dropSocket(current);
if (!disposed) {
void pollOnce();
scheduleReconnect();
}
}
}, WS_OPEN_TIMEOUT_MS);
const handleDrop = (): void => {
if (settled && socket !== current) {
return;
}
settled = true;
window.clearTimeout(openTimeout);
window.clearInterval(heartbeatTimer);
dropSocket(current);
if (!disposed) {
void pollOnce();
scheduleReconnect();
}
};
current.onmessage = (event: MessageEvent) => {
let message: { op: number; t?: string; d?: unknown };
try {
message = JSON.parse(event.data as string) as {
op: number;
t?: string;
d?: unknown;
};
} catch {
return;
}
if (message.op === 1) {
settled = true;
window.clearTimeout(openTimeout);
reconnectDelay = RECONNECT_BASE_MS;
const interval =
(message.d as { heartbeat_interval?: number } | undefined)
?.heartbeat_interval ?? 30_000;
window.clearInterval(heartbeatTimer);
heartbeatTimer = window.setInterval(() => {
try {
current.send(JSON.stringify({ op: 3 }));
} catch {
handleDrop();
}
}, interval);
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
} else if (
message.op === 0 &&
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
) {
if (!disposed) {
setData(message.d as LanyardData);
}
}
};
current.onerror = handleDrop;
current.onclose = handleDrop;
}
connect();
const safetyTimer = window.setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
void pollOnce();
}
}, SAFETY_POLL_MS);
return () => {
disposed = true;
window.clearTimeout(reconnectTimer);
window.clearInterval(safetyTimer);
window.clearInterval(heartbeatTimer);
if (socket) {
const current = socket;
socket = null;
current.onclose = null;
current.onerror = null;
current.onmessage = null;
try {
current.close();
} catch {
socket = null;
}
}
};
}, []);
const data = useLanyard(initial);
if (!data) {
return (
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GameProfileModal } from "@/components/game-profile-modal";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { profileFixture } from "@/test/fixtures";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("GameProfileModal", () => {
it("loads and renders the game profile", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => profileFixture() }) as Response),
);
const dict = getDictionary("en");
render(
<GameProfileModal
applicationId="1"
heroSeed={0}
lang="en"
iconUrl={null}
fallbackName="Fallback"
fallbackGameUrl="https://example.com"
dict={dict.discord.gameModal}
onClose={() => {}}
/>,
);
expect(await screen.findByText("Test Game")).toBeDefined();
expect(screen.getByText("A test game")).toBeDefined();
});
it("shows no-game-data without retry on deterministic miss", async () => {
const deterministic = profileFixture({
steamId: null,
steamUrl: null,
sources: { igdb: false, steam: false, itad: false },
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => deterministic }) as Response),
);
const dict = getDictionary("en");
render(
<GameProfileModal
applicationId="1"
heroSeed={0}
lang="en"
iconUrl={null}
fallbackName="Fallback"
fallbackGameUrl="https://example.com"
dict={dict.discord.gameModal}
onClose={() => {}}
/>,
);
expect(await screen.findByText(dict.discord.gameModal.noGameData)).toBeDefined();
expect(screen.queryByText(dict.discord.gameModal.retry)).toBeNull();
});
it("shows the error state with retry", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
const dict = getDictionary("en");
render(
<GameProfileModal
applicationId="1"
heroSeed={0}
lang="en"
iconUrl={null}
fallbackName="Fallback"
fallbackGameUrl="https://example.com"
dict={dict.discord.gameModal}
onClose={() => {}}
/>,
);
expect(await screen.findByText(dict.discord.gameModal.loadError)).toBeDefined();
});
});
+53 -381
View File
@@ -1,91 +1,12 @@
"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;
}
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,
@@ -99,48 +20,26 @@ export function GameProfileModal({
}: {
applicationId: string;
heroSeed: number;
lang: string;
lang: Locale;
iconUrl: string | null;
fallbackName: string;
fallbackGameUrl: string;
dict: ModalDict;
onClose: () => void;
}) {
const [profile, setProfile] = useState<GameProfile | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
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);
setFailed(false);
}
} catch {
if (!cancelled) {
setFailed(true);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [applicationId, lang, country, attempt]);
const partialFail =
!!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad;
const {
profile,
failed,
localeTag,
hero,
hasDetails,
partialFail,
isDeterministicMiss,
canRetry,
retry,
reset,
setBannerPick,
} = useGameProfile(applicationId, lang, heroSeed);
useEffect(() => {
function onKey(event: KeyboardEvent): void {
@@ -157,72 +56,11 @@ export function GameProfileModal({
};
}, [onClose]);
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
const name = profile?.name ?? fallbackName;
const best = profile?.itad?.prices ?? null;
const low = profile?.itad?.historyLow ?? null;
const isFree = !!best && best.price === 0;
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 { gameUrl, name, bestPrice, historyLow, isFree } = selectGameView(
profile,
fallbackName,
fallbackGameUrl,
);
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
@@ -301,26 +139,26 @@ export function GameProfileModal({
<p>{dict.loadError}</p>
<button
type="button"
onClick={() => {
setFailed(false);
setAttempt((n) => n + 1);
}}
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"
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}
{partialFail ? (
{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={() => {
setProfile(null);
setAttempt((n) => n + 1);
}}
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"
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>
@@ -346,7 +184,7 @@ export function GameProfileModal({
) : null}
{profile?.steamId ? (
<a
href={`https://steamdb.info/app/${profile.steamId}/`}
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"
@@ -384,7 +222,7 @@ export function GameProfileModal({
) : null}
</div>
{hasDetails ? (
{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">
@@ -412,7 +250,7 @@ export function GameProfileModal({
{dict.releaseDate}
</dt>
<dd className="text-zinc-200">
{formatReleaseDate(profile.releaseDate, tag)}
{formatReleaseDate(profile.releaseDate, localeTag)}
</dd>
</div>
) : null}
@@ -447,193 +285,27 @@ export function GameProfileModal({
</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>
{profile ? (
<ModalReviews profile={profile} dict={dict} localeTag={localeTag} />
) : null}
{profile?.itad && (best || low) ? (
<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">
{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>
{profile?.itad && (bestPrice || historyLow) ? (
<ModalPrices
dict={dict}
localeTag={localeTag}
bestPrice={bestPrice}
historyLow={historyLow}
isFree={isFree}
/>
) : 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"
<ModalScreenshots
profile={profile}
dict={dict}
hero={hero}
onPick={setBannerPick}
/>
</button>
))}
</div>
</div>
) : null}
</div>
</div>
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { ModalPrices, ModalReviews } from "@/components/game-profile-sections";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import type { GamePricePoint } from "@/lib/game/game-profile-types";
import { profileFixture } from "@/test/fixtures";
describe("ModalReviews", () => {
it("renders nothing without review sources", () => {
const dict = getDictionary("en");
const { container } = render(
<ModalReviews
profile={profileFixture()}
dict={dict.discord.gameModal}
localeTag="en-US"
/>,
);
expect(container.textContent).toBe("");
});
it("renders Steam review data", () => {
const dict = getDictionary("en");
render(
<ModalReviews
profile={profileFixture({
steamReviews: {
label: "Positive",
total: 100,
positive: 90,
negative: 10,
url: "https://store.steampowered.com/app/100/#app_reviews_hash",
},
})}
dict={dict.discord.gameModal}
localeTag="en-US"
/>,
);
expect(screen.getByText("Positive")).toBeDefined();
});
});
describe("ModalPrices", () => {
it("renders the free badge", () => {
const dict = getDictionary("en");
const bestPrice: GamePricePoint = {
price: 0,
regular: 0,
currency: "USD",
shop: "Steam",
url: "",
};
render(
<ModalPrices
dict={dict.discord.gameModal}
localeTag="en-US"
bestPrice={bestPrice}
historyLow={null}
isFree
/>,
);
expect(screen.getByText(dict.discord.gameModal.free)).toBeDefined();
});
});
+262
View File
@@ -0,0 +1,262 @@
"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>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LanguageSwitcher } from "@/components/language-switcher";
afterEach(() => {
vi.restoreAllMocks();
});
describe("LanguageSwitcher", () => {
it("marks the current locale pressed and switches on click", () => {
Object.defineProperty(window, "location", {
configurable: true,
value: { reload: vi.fn() },
});
let cookie = "";
Object.defineProperty(document, "cookie", {
configurable: true,
get: () => cookie,
set: (v: string) => {
cookie = v;
},
});
render(<LanguageSwitcher current="tr" />);
expect(screen.getByText("TR").getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByText("EN"));
expect(cookie).toContain("locale=en");
expect(window.location.reload).toHaveBeenCalledOnce();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, isLocale, type Locale } from "@/lib/i18n/locales";
import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, type Locale } from "@/lib/i18n/locales";
function writeLocaleCookie(locale: Locale): void {
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`;
@@ -8,7 +8,7 @@ function writeLocaleCookie(locale: Locale): void {
export function LanguageSwitcher({ current }: { current: Locale }) {
function switchTo(locale: Locale) {
if (locale === current || !isLocale(locale)) {
if (locale === current) {
return;
}
writeLocaleCookie(locale);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { ProjectsSection } from "@/components/projects-section";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { repoFixture } from "@/test/fixtures";
describe("ProjectsSection", () => {
it("renders repositories", () => {
const dict = getDictionary("en");
render(<ProjectsSection repos={[repoFixture()]} dict={dict.projects} locale="en" />);
expect(screen.getByText("demo")).toBeDefined();
expect(screen.getByText("A demo repo")).toBeDefined();
cleanup();
});
it("renders the quiet state without repositories", () => {
const dict = getDictionary("en");
render(<ProjectsSection repos={[]} dict={dict.projects} locale="en" />);
expect(screen.getAllByText(dict.projects.title).length).toBeGreaterThan(0);
cleanup();
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
import type { GiteaRepo } from "@/lib/gitea";
import { SectionHeading } from "./section-heading";
import { SectionHeading } from "@/components/section-heading";
const LANGUAGE_COLORS: Record<string, string> = {
const LANGUAGE_COLORS: Partial<Record<string, string>> = {
"C#": "#178600",
TypeScript: "#3178c6",
JavaScript: "#f1e05a",
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { SectionHeading } from "@/components/section-heading";
describe("SectionHeading", () => {
it("renders kicker, title, and subtitle", () => {
render(<SectionHeading kicker="src" title="Title" subtitle="Sub" />);
expect(screen.getByText("// src")).toBeDefined();
expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Title");
expect(screen.getByText("Sub")).toBeDefined();
});
it("omits subtitle when absent", () => {
const { container } = render(<SectionHeading kicker="src" title="Title" />);
expect(container.querySelector("p.mt-2")).toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { SiteFooter } from "@/components/site-footer";
import { getDictionary } from "@/lib/i18n/get-dictionary";
describe("SiteFooter", () => {
it("renders brand, rights, and source links", () => {
const dict = getDictionary("en");
const { container } = render(<SiteFooter dict={dict.footer} />);
expect(container.querySelector('a[href="https://fristy.net"]')).not.toBeNull();
expect(container.textContent).toContain(dict.footer.rights);
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThanOrEqual(3);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { SiteHeader } from "@/components/site-header";
import { getDictionary } from "@/lib/i18n/get-dictionary";
describe("SiteHeader", () => {
it("renders brand, nav anchors, and language options", () => {
const dict = getDictionary("tr");
const { container } = render(<SiteHeader dict={dict.nav} locale="tr" />);
expect(container.querySelector('a[href="#top"]')).not.toBeNull();
expect(screen.getByText(dict.nav.discord)).toBeDefined();
expect(screen.getByRole("group", { name: "language" })).toBeDefined();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
import { LanguageSwitcher } from "./language-switcher";
import { LanguageSwitcher } from "@/components/language-switcher";
export function SiteHeader({ dict, locale }: { dict: Dictionary["nav"]; locale: Locale }) {
return (
+8
View File
@@ -0,0 +1,8 @@
services:
web:
image: git.fristy.net/fristy/fristynet:latest
restart: unless-stopped
environment:
TWITCH_CLIENT_ID: ${TWITCH_CLIENT_ID}
TWITCH_CLIENT_SECRET: ${TWITCH_CLIENT_SECRET}
ITAD_API_KEY: ${ITAD_API_KEY}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useGameProfile } from "@/hooks/use-game-profile";
import { profileFixture } from "@/test/fixtures";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("useGameProfile", () => {
it("loads a profile and derives view state", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => profileFixture() }) as Response),
);
const { result } = renderHook(() => useGameProfile("1", "en", 0));
await waitFor(() => expect(result.current.profile?.name).toBe("Test Game"));
expect(result.current.failed).toBe(false);
expect(result.current.localeTag).toBe("en-US");
expect(result.current.hasDetails).toBe(true);
});
it("flags failure when the API errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
const { result } = renderHook(() => useGameProfile("1", "en", 0));
await waitFor(() => expect(result.current.failed).toBe(true));
expect(result.current.profile).toBeNull();
});
it("retries on demand", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
return { ok: false, status: 500 } as Response;
}),
);
const { result } = renderHook(() => useGameProfile("1", "en", 0));
await waitFor(() => expect(result.current.failed).toBe(true));
expect(result.current.canRetry).toBe(true);
result.current.retry();
await waitFor(() => expect(calls).toBeGreaterThan(1));
});
it("blocks double retry during cooldown and restores on reset", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
return { ok: false, status: 500 } as Response;
}),
);
const { result } = renderHook(() => useGameProfile("1", "en", 0));
await waitFor(() => expect(result.current.failed).toBe(true));
act(() => {
result.current.retry();
});
expect(result.current.canRetry).toBe(false);
const afterFirstRetry = calls;
act(() => {
result.current.retry();
});
expect(calls).toBe(afterFirstRetry);
act(() => {
result.current.reset();
});
expect(result.current.canRetry).toBe(true);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { GameProfile, ItadReview } from "@/lib/game/game-profile-types";
import type { Locale } from "@/lib/i18n/locales";
import { describeProfile, resolveGameLocale } from "@/lib/game/game-format";
import { fetchJson } from "@/lib/client-fetch";
export const MAX_RETRIES = 3;
export const RETRY_COOLDOWN_MS = 5_000;
export interface GameProfileState {
profile: GameProfile | null;
failed: boolean;
localeTag: string;
hero: string | null;
dbRating: number | null;
itadReviews: ItadReview[];
hasDetails: boolean;
hasReviews: boolean;
partialFail: boolean;
metascore: ItadReview | null;
opencritic: ItadReview | null;
isDeterministicMiss: boolean;
canRetry: boolean;
setBannerPick: (shot: string | null) => void;
retry: () => void;
reset: () => void;
}
export function useGameProfile(
applicationId: string,
lang: Locale,
heroSeed: number,
): GameProfileState {
const [profile, setProfile] = useState<GameProfile | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [bannerPick, setBannerPick] = useState<string | null>(null);
const [retries, setRetries] = useState(0);
const [cooling, setCooling] = useState(false);
const coolingTimer = useRef<number | undefined>(undefined);
const { localeTag, country } = resolveGameLocale(lang);
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
const json = await fetchJson<GameProfile>(
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
);
if (!cancelled) {
if (json) {
setProfile(json);
setFailed(false);
} else {
setFailed(true);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [applicationId, lang, country, attempt]);
const partialFail =
!!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad;
const isDeterministicMiss =
!!profile && !profile.steamId && !profile.sources.igdb && !profile.sources.itad;
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 summary = describeProfile(profile);
const canRetry = retries < MAX_RETRIES && !cooling;
function retry(): void {
if (!canRetry) {
return;
}
setCooling(true);
setRetries((n) => n + 1);
setFailed(false);
setAttempt((n) => n + 1);
window.clearTimeout(coolingTimer.current);
coolingTimer.current = window.setTimeout(() => setCooling(false), RETRY_COOLDOWN_MS);
}
function reset(): void {
window.clearTimeout(coolingTimer.current);
setCooling(false);
setRetries(0);
setProfile(null);
setFailed(false);
setAttempt((n) => n + 1);
}
useEffect(() => () => window.clearTimeout(coolingTimer.current), []);
return {
profile,
failed,
localeTag,
hero,
...summary,
partialFail,
isDeterministicMiss,
canRetry,
setBannerPick,
retry,
reset,
};
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useLanyard } from "@/hooks/use-lanyard";
import { lanyardFixture } from "@/test/fixtures";
describe("useLanyard", () => {
it("returns the initial snapshot synchronously", () => {
const initial = lanyardFixture();
const { result, unmount } = renderHook(() => useLanyard(initial));
expect(result.current?.discord_user.username).toBe("testuser");
unmount();
});
it("starts empty without initial data", () => {
const { result, unmount } = renderHook(() => useLanyard(null));
expect(result.current).toBeNull();
unmount();
});
});
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import type { LanyardData } from "@/lib/game/lanyard";
import { DISCORD_ID, LANYARD_API, LANYARD_WS } from "@/lib/game/lanyard";
import { fetchJson } from "@/lib/client-fetch";
const WS_OPEN_TIMEOUT_MS = 10_000;
const RECONNECT_BASE_MS = 2_000;
const RECONNECT_MAX_MS = 30_000;
const SAFETY_POLL_MS = 60_000;
const HEARTBEAT_FALLBACK_MS = 30_000;
interface LanyardMessage {
op: number;
t?: string;
d?: unknown;
}
function parseLanyardMessage(data: unknown): LanyardMessage | null {
if (typeof data !== "string") {
return null;
}
try {
return JSON.parse(data) as LanyardMessage;
} catch {
return null;
}
}
function isLanyardData(value: unknown): value is LanyardData {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as { activities?: unknown; discord_status?: unknown };
if (!Array.isArray(candidate.activities)) {
return false;
}
const status = candidate.discord_status;
return status === "online" || status === "idle" || status === "dnd" || status === "offline";
}
function nextBackoffDelay(current: number): number {
return Math.min(current * 2, RECONNECT_MAX_MS);
}
async function fetchLanyardSnapshot(): Promise<LanyardData | null> {
const json = await fetchJson<{ success: boolean; data: LanyardData }>(LANYARD_API, {
cache: "no-store",
});
return json && json.success ? json.data : null;
}
interface LanyardSocketEvents {
onData: (data: LanyardData) => void;
}
interface LanyardSocket {
start: () => void;
stop: () => void;
}
function createLanyardSocket(events: LanyardSocketEvents): LanyardSocket {
let disposed = false;
let socket: WebSocket | null = null;
let heartbeatTimer: number | undefined;
let reconnectTimer: number | undefined;
let safetyTimer: number | undefined;
let reconnectDelay = RECONNECT_BASE_MS;
async function pollOnce(): Promise<void> {
const snapshot = await fetchLanyardSnapshot();
if (snapshot && !disposed) {
events.onData(snapshot);
}
}
function scheduleReconnect(): void {
if (disposed) {
return;
}
window.clearTimeout(reconnectTimer);
reconnectTimer = window.setTimeout(() => {
reconnectDelay = nextBackoffDelay(reconnectDelay);
connect();
}, reconnectDelay);
}
function dropSocket(current: WebSocket): void {
current.onclose = null;
current.onerror = null;
current.onmessage = null;
if (socket === current) {
socket = null;
}
try {
current.close();
} catch {
if (socket === current) {
socket = null;
}
}
}
function connect(): void {
if (disposed) {
return;
}
let current: WebSocket;
try {
current = new WebSocket(LANYARD_WS);
} catch {
scheduleReconnect();
return;
}
socket = current;
let settled = false;
const openTimeout = window.setTimeout(() => {
if (!settled) {
settled = true;
dropSocket(current);
if (!disposed) {
void pollOnce();
scheduleReconnect();
}
}
}, WS_OPEN_TIMEOUT_MS);
const handleDrop = (): void => {
if (settled && socket !== current) {
return;
}
settled = true;
window.clearTimeout(openTimeout);
window.clearInterval(heartbeatTimer);
dropSocket(current);
if (!disposed) {
void pollOnce();
scheduleReconnect();
}
};
current.onmessage = (event: MessageEvent) => {
const message = parseLanyardMessage(event.data);
if (!message) {
return;
}
if (message.op === 1) {
settled = true;
window.clearTimeout(openTimeout);
reconnectDelay = RECONNECT_BASE_MS;
const interval =
(message.d as { heartbeat_interval?: number } | undefined)
?.heartbeat_interval ?? HEARTBEAT_FALLBACK_MS;
window.clearInterval(heartbeatTimer);
heartbeatTimer = window.setInterval(() => {
try {
current.send(JSON.stringify({ op: 3 }));
} catch {
handleDrop();
}
}, interval);
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
} else if (
message.op === 0 &&
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
) {
if (!disposed && isLanyardData(message.d)) {
events.onData(message.d);
}
}
};
current.onerror = handleDrop;
current.onclose = handleDrop;
}
function start(): void {
connect();
safetyTimer = window.setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
void pollOnce();
}
}, SAFETY_POLL_MS);
}
function stop(): void {
disposed = true;
window.clearTimeout(reconnectTimer);
window.clearInterval(safetyTimer);
window.clearInterval(heartbeatTimer);
if (socket) {
dropSocket(socket);
}
}
return { start, stop };
}
export function useLanyard(initial: LanyardData | null): LanyardData | null {
const [data, setData] = useState<LanyardData | null>(initial);
useEffect(() => {
const controller = createLanyardSocket({ onData: (snapshot) => setData(snapshot) });
controller.start();
return () => controller.stop();
}, []);
return data;
}
-19
View File
@@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
const ALLOWED_ORIGIN = "https://fristy.net";
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
return origin === null || origin === ALLOWED_ORIGIN;
}
export function corsHeaders(): HeadersInit {
return {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
Vary: "Origin",
};
}
export function forbidden(): NextResponse {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { invalidIdResponse, jsonErrorWithCors, jsonWithCors, parseAppId } from "@/lib/api-route";
function contextWith(id: string) {
return { params: Promise.resolve({ id }) };
}
describe("parseAppId", () => {
it("accepts numeric ids", async () => {
await expect(parseAppId(contextWith("12345"))).resolves.toBe("12345");
});
it("rejects non-numeric ids", async () => {
await expect(parseAppId(contextWith("abc"))).resolves.toBeNull();
await expect(parseAppId(contextWith(""))).resolves.toBeNull();
});
});
describe("invalidIdResponse", () => {
it("returns a 400 JSON response", async () => {
const response = invalidIdResponse();
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid application id" });
});
});
describe("jsonErrorWithCors", () => {
it("sets status, cache, and CORS headers", async () => {
const response = jsonErrorWithCors({ error: "Nope" }, 404);
expect(response.status).toBe(404);
expect(response.headers.get("Cache-Control")).toContain("s-maxage=60");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
await expect(response.json()).resolves.toEqual({ error: "Nope" });
});
});
describe("jsonWithCors", () => {
it("sets cache and CORS headers", async () => {
const response = jsonWithCors({ ok: true }, 3600);
expect(response.headers.get("Cache-Control")).toContain("s-maxage=3600");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
await expect(response.json()).resolves.toEqual({ ok: true });
});
it("echoes a validated dev origin instead of the production URL", async () => {
const response = jsonWithCors({ ok: true }, 3600, "http://localhost:3000");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:3000");
});
it("falls back to production for untrusted origins", async () => {
const response = jsonWithCors({ ok: true }, 3600, "https://evil.example");
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
});
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { DEV_ORIGINS, SITE_URL } from "@/lib/site";
const ALLOWED_ORIGIN = SITE_URL;
function isDev(): boolean {
return process.env.NODE_ENV !== "production";
}
export function isAllowedOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
if (origin === null) {
return true;
}
if (origin === ALLOWED_ORIGIN) {
return true;
}
if (isDev() && DEV_ORIGINS.has(origin)) {
return true;
}
return false;
}
function resolveAllowedOrigin(origin: string | null): string {
if (origin !== null && (origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin)))) {
return origin;
}
return ALLOWED_ORIGIN;
}
export function corsHeaders(origin?: string | null): Record<string, string> {
return {
"Access-Control-Allow-Origin": resolveAllowedOrigin(origin ?? null),
Vary: "Origin",
};
}
export function forbidden(origin?: string | null): NextResponse {
return NextResponse.json(
{ error: "Forbidden" },
{
status: 403,
headers: {
"Cache-Control": "public, s-maxage=0, stale-while-revalidate=86400",
...corsHeaders(origin),
},
},
);
}
export async function parseAppId(context: {
params: Promise<{ id: string }>;
}): Promise<string | null> {
const { id } = await context.params;
if (!/^\d{1,20}$/.test(id)) {
return null;
}
return id;
}
export function invalidIdResponse(origin?: string | null): NextResponse {
return jsonErrorWithCors({ error: "Invalid application id" }, 400, 0, origin);
}
export function jsonErrorWithCors(
data: unknown,
status: number,
sMaxage = 60,
origin?: string | null,
): NextResponse {
return NextResponse.json(data, {
status,
headers: {
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
...corsHeaders(origin),
},
});
}
export function jsonWithCors(data: unknown, sMaxage = 3600, origin?: string | null): NextResponse {
return NextResponse.json(data, {
headers: {
"Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`,
...corsHeaders(origin),
},
});
}
export function requestOrigin(request: Request): string | null {
return request.headers.get("origin");
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchJson } from "@/lib/client-fetch";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchJson", () => {
it("returns parsed JSON on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ a: 1 }) }) as Response),
);
await expect(fetchJson<{ a: number }>("https://example.com")).resolves.toEqual({ a: 1 });
});
it("returns null on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(fetchJson("https://example.com")).resolves.toBeNull();
});
it("returns null on network failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(fetchJson("https://example.com")).resolves.toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { warnDev } from "@/lib/log";
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {
try {
const response = await fetch(url, init);
if (!response.ok) {
warnDev(`client fetch failed: ${url.split("?")[0]} -> ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch {
warnDev(`client fetch failed: ${url.split("?")[0]}`);
return null;
}
}
-635
View File
@@ -1,635 +0,0 @@
import { getDiscordAppInfo } from "@/lib/lanyard";
export interface GameExternalLink {
label: string;
url: string;
}
export interface GameReviewSummary {
label: string;
total: number | null;
positive: number | null;
negative: number | null;
url?: string;
}
export function steamdbRating(positive: number, total: number): number | null {
if (
!Number.isFinite(positive) ||
!Number.isFinite(total) ||
total <= 0 ||
positive < 0 ||
positive > total
) {
return null;
}
const score = positive / total;
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
}
export interface GamePricePoint {
price: number;
regular: number;
currency: string;
shop: string;
url: string;
}
export interface ItadReview {
score: number | null;
source: string;
count: number | null;
url: string;
}
export interface GameProfile {
applicationId: string;
gameId: string;
gameUrl: string;
name: string;
genres: string[];
description: string | null;
developers: string[];
publishers: string[];
releaseDate: string | null;
platforms: string[];
links: GameExternalLink[];
coverUrl: string | null;
artworkUrl: string | null;
screenshots: string[];
steamId: string | null;
steamUrl: string | null;
igdbUrl: string | null;
steamReviews: GameReviewSummary | null;
itad: {
prices: GamePricePoint | null;
historyLow: GamePricePoint | null;
pageUrl: string | null;
playerCount: number | null;
reviews: ItadReview[];
} | null;
sources: {
igdb: boolean;
steam: boolean;
itad: boolean;
};
}
interface DetectableEntry {
id: string;
name: string;
third_party_skus?: Array<{ distributor: string; id: string }>;
}
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
async function getDetectables(): Promise<DetectableEntry[]> {
if (detectablesCache && Date.now() < detectablesCache.expires) {
return detectablesCache.data;
}
try {
const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json");
if (!response.ok) {
console.error(`detectables failed: ${response.status}`);
return detectablesCache?.data ?? [];
}
const data = (await response.json()) as DetectableEntry[];
detectablesCache = { data, expires: Date.now() + 86_400_000 };
return data;
} catch {
return detectablesCache?.data ?? [];
}
}
const DISTRIBUTOR_PLATFORM: Record<string, string> = {
xbox: "Xbox",
xbox_title: "Xbox",
xbox_game_pass: "Xbox",
playstation: "PlayStation",
nintendo: "Switch",
};
function steamIdFromEntry(entry: DetectableEntry | undefined): string | null {
const sku = entry?.third_party_skus?.find(
(s) => s.distributor === "steam" && /^\d+$/.test(s.id),
);
return sku?.id ?? null;
}
interface SteamAppDetails {
success: boolean;
data?: {
name?: string;
short_description?: string;
header_image?: string;
genres?: Array<{ description?: string }>;
developers?: string[];
publishers?: string[];
platforms?: { windows?: boolean; mac?: boolean; linux?: boolean };
release_date?: { date?: string };
screenshots?: Array<{ path_full?: string }>;
};
}
const STEAM_LANG: Record<string, string> = {
tr: "turkish",
en: "english",
de: "german",
};
async function getSteamDetails(
steamId: string,
lang = "en",
): Promise<SteamAppDetails["data"] | null> {
try {
const steamLang = STEAM_LANG[lang] ?? "english";
const response = await fetch(
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`,
{ next: { revalidate: 86_400 } },
);
if (!response.ok) {
console.error(`steam details failed: ${steamId} ${response.status}`);
return null;
}
const json = (await response.json()) as Record<string, SteamAppDetails>;
const entry = json[steamId];
return entry?.success ? (entry.data ?? null) : null;
} catch {
return null;
}
}
interface SteamReviewQuery {
review_score_desc?: string;
total_positive?: number;
total_negative?: number;
total_reviews?: number;
}
async function getSteamReviewSummary(steamId: string): Promise<GameReviewSummary | null> {
try {
const url =
`https://store.steampowered.com/appreviews/${steamId}` +
"?json=1&language=all&purchase_type=all&num_per_page=0";
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) {
console.error(`steam reviews failed: ${steamId} ${response.status}`);
return null;
}
const json = (await response.json()) as { query_summary?: SteamReviewQuery };
const summary = json.query_summary;
if (!summary || typeof summary.total_reviews !== "number") {
return null;
}
return {
label: summary.review_score_desc ?? "",
total: summary.total_reviews,
positive: summary.total_positive ?? null,
negative: summary.total_negative ?? null,
url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`,
};
} catch {
return null;
}
}
interface IgdbGame {
name?: string;
slug?: string;
genres?: Array<{ name?: string }>;
summary?: string;
storyline?: string;
cover?: { url?: string };
artworks?: Array<{ url?: string }>;
screenshots?: Array<{ url?: string }>;
websites?: Array<{ url?: string; category?: number }>;
involved_companies?: Array<{
developer?: boolean;
publisher?: boolean;
company?: { name?: string };
}>;
platforms?: Array<{ abbreviation?: string; name?: string }>;
first_release_date?: number;
}
let igdbTokenCache: { token: string; expires: number } | null = null;
let igdbKeyWarned = false;
async function getIgdbToken(): Promise<string | null> {
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
if (!igdbKeyWarned) {
igdbKeyWarned = true;
console.error("igdb keys missing: set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET");
}
return null;
}
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
return igdbTokenCache.token;
}
try {
const response = await fetch("https://id.twitch.tv/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "client_credentials",
}),
});
if (!response.ok) {
console.error(`igdb token failed: ${response.status}`);
return null;
}
const json = (await response.json()) as { access_token?: string; expires_in?: number };
if (!json.access_token) {
return null;
}
igdbTokenCache = {
token: json.access_token,
expires: Date.now() + (json.expires_in ?? 3600) * 1000 - 60_000,
};
return json.access_token;
} catch {
return null;
}
}
async function igdbQuery<T>(token: string, endpoint: string, body: string): Promise<T | null> {
try {
const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
headers: {
"Client-ID": process.env.TWITCH_CLIENT_ID ?? "",
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body,
next: { revalidate: 86_400 },
});
if (!response.ok) {
console.error(`igdb query failed: ${endpoint} ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch {
return null;
}
}
const IGDB_GAME_FIELDS =
"name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," +
"websites.url,websites.category,involved_companies.developer," +
"involved_companies.publisher,involved_companies.company.name," +
"platforms.abbreviation,platforms.name,first_release_date;";
function igdbImage(url: string | undefined, size: string): string | null {
if (!url) {
return null;
}
const full = url.startsWith("//") ? `https:${url}` : url;
return full.replace(/t_thumb|t_cover_small|t_logo_med/, size);
}
async function getIgdbGame(name: string, steamId: string | null): Promise<IgdbGame | null> {
const token = await getIgdbToken();
if (!token) {
return null;
}
if (steamId) {
const external = await igdbQuery<Array<{ game?: number }>>(
token,
"external_games",
`fields game; where uid = "${steamId}"; limit 1;`,
);
const gameId = external?.[0]?.game;
if (gameId) {
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`,
);
if (games?.[0]) {
return games[0];
}
}
}
const cleaned = name
.replace(/[®™©]/g, "")
.replace(/\s{2,}/g, " ")
.trim();
const norm = (v: string): string =>
v
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const escaped = cleaned.replace(/"/g, '\\"');
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`,
);
if (!games || games.length === 0) {
return null;
}
const wanted = norm(name);
return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null;
}
const IGDB_WEBSITE_LABELS: Record<number, string> = {
1: "Website",
4: "Facebook",
5: "X",
6: "Twitch",
8: "Instagram",
9: "YouTube",
13: "Steam",
14: "Reddit",
16: "Epic",
17: "GOG",
18: "Discord",
};
interface ItadGameInfo {
title?: string;
assets?: { boxart?: string; banner600?: string };
tags?: string[];
releaseDate?: string | null;
developers?: Array<{ name?: string }>;
publishers?: Array<{ name?: string }>;
reviews?: Array<{
score?: number | null;
source?: string;
count?: number | null;
url?: string;
}>;
players?: { recent?: number } | null;
urls?: { game?: string };
}
interface ItadOverviewPrice {
current?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
url?: string;
} | null;
lowest?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
} | null;
}
let itadKeyWarned = false;
async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
const key = process.env.ITAD_API_KEY;
if (!key) {
if (!itadKeyWarned) {
itadKeyWarned = true;
console.error("itad key missing: set ITAD_API_KEY");
}
return null;
}
try {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(
`https://api.isthereanydeal.com${path}${separator}key=${key}`,
{
...init,
next: { revalidate: 3600 },
},
);
if (!response.ok) {
console.error(`itad failed: ${path.split("?")[0]} ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch {
return null;
}
}
async function getItadId(title: string): Promise<string | null> {
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
`/games/search/v1?title=${encodeURIComponent(title)}&results=5`,
);
if (!results || results.length === 0) {
return null;
}
const norm = (value: string): string =>
value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const wanted = norm(title);
return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null;
}
function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export async function getGameProfile(
applicationId: string,
country = "TR",
lang = "en",
): Promise<GameProfile | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
const appInfo = await getDiscordAppInfo(applicationId);
if (!appInfo) {
return null;
}
const detectables = await getDetectables();
const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId);
const steamId = steamIdFromEntry(entry);
const [steam, steamReviews, igdb] = await Promise.all([
steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null),
steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null),
getIgdbGame(appInfo.name || entry?.name || "", steamId),
]);
const itadId = process.env.ITAD_API_KEY
? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name)
: null;
const [itadInfo, itadOverview] = itadId
? await Promise.all([
itadFetch<ItadGameInfo>(`/games/info/v2?id=${itadId}`),
itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([itadId]),
}),
])
: [null, null];
const companies = igdb?.involved_companies ?? [];
const devList: string[] =
companies.length > 0
? [
...new Set(
companies
.filter((c) => c.developer)
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
]
: (steam?.developers ??
(itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || []));
const pubList: string[] =
companies.length > 0
? [
...new Set(
companies
.filter((c) => c.publisher)
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
]
: (steam?.publishers ??
(itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || []));
const igdbPlatforms = new Set<string>();
for (const p of igdb?.platforms ?? []) {
const raw = (p.name ?? "").trim();
if (!raw) {
continue;
}
if (raw === "PC (Microsoft Windows)") {
igdbPlatforms.add("Windows");
} else if (raw === "Mac") {
igdbPlatforms.add("MacOS");
} else {
igdbPlatforms.add(raw);
}
}
const platforms = new Set<string>(igdbPlatforms);
if (platforms.size === 0 && steam?.platforms) {
if (steam.platforms.windows) {
platforms.add("Windows");
}
if (steam.platforms.mac) {
platforms.add("MacOS");
}
if (steam.platforms.linux) {
platforms.add("Linux");
}
for (const sku of entry?.third_party_skus ?? []) {
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
if (mapped) {
platforms.add(mapped);
}
}
}
const links: GameExternalLink[] = [];
for (const site of igdb?.websites ?? []) {
if (!site.url) {
continue;
}
const url = site.url.startsWith("http") ? site.url : `https://${site.url}`;
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
if (!label) {
try {
label = new URL(url).hostname.replace(/^www\./, "");
} catch {
continue;
}
}
if (!links.some((l) => l.url === url)) {
links.push({ label, url });
}
}
const screenshots: string[] = [
...(igdb?.screenshots ?? [])
.map((s) => igdbImage(s.url, "t_screenshot_big"))
.filter((u): u is string => !!u),
...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u),
].slice(0, 8);
const priceEntry = itadOverview?.prices?.[0];
const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => {
if (!p?.price || typeof p.price.amount !== "number") {
return null;
}
return {
price: p.price.amount,
regular: p.regular?.amount ?? p.price.amount,
currency: p.price.currency ?? "",
shop: p.shop?.name ?? "",
url: p.url ?? "",
};
};
return {
applicationId,
gameId: appInfo.gameId,
gameUrl: appInfo.gameUrl,
name: igdb?.name ?? steam?.name ?? appInfo.name,
genres: [
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
].slice(0, 6),
description:
lang === "en"
? (igdb?.summary ?? steam?.short_description ?? null)
: (steam?.short_description ?? igdb?.summary ?? null),
developers: devList,
publishers: pubList,
releaseDate:
toIsoDate(igdb?.first_release_date) ??
itadInfo?.releaseDate ??
steam?.release_date?.date ??
null,
platforms: [...platforms],
links,
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
artworkUrl:
igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ??
steam?.header_image ??
itadInfo?.assets?.banner600 ??
null,
screenshots,
steamId,
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null,
steamReviews,
itad:
process.env.ITAD_API_KEY && (itadInfo || priceEntry)
? {
prices: toPoint(priceEntry?.current),
historyLow: toPoint(priceEntry?.lowest),
pageUrl: itadInfo?.urls?.game ?? null,
playerCount: itadInfo?.players?.recent ?? null,
reviews: (itadInfo?.reviews ?? [])
.filter((r) => r.source && r.url)
.map((r) => ({
score: r.score ?? null,
source: r.source as string,
count: r.count ?? null,
url: r.url as string,
})),
}
: null,
sources: {
igdb: !!igdb,
steam: !!steam,
itad: !!itadInfo,
},
};
}
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetDetectablesCache, getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetDetectablesCache();
});
describe("steamIdFromEntry", () => {
it("extracts numeric Steam SKUs", () => {
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "12345" }],
}),
).toBe("12345");
});
it("rejects non-numeric or non-Steam SKUs", () => {
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "abc" }],
}),
).toBeNull();
expect(
steamIdFromEntry({
id: "game",
name: "Game",
third_party_skus: [{ distributor: "gog", id: "12345" }],
}),
).toBeNull();
});
it("handles missing entries", () => {
expect(steamIdFromEntry(undefined)).toBeNull();
});
});
describe("getDetectables", () => {
it("caches the snapshot within the TTL", async () => {
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => [{ id: "1", name: "Game" }],
}) as Response,
);
vi.stubGlobal("fetch", fetchMock);
const first = await getDetectables();
const second = await getDetectables();
expect(first).toHaveLength(1);
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("falls back to empty when fetch rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getDetectables()).resolves.toEqual([]);
});
it("falls back to empty on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getDetectables()).resolves.toEqual([]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { warnDev } from "@/lib/log";
import type { DetectableEntry } from "@/lib/game/game-profile-types";
const DETECTABLES_URL = "https://cdn.discordapp.com/detectables/games-v1.json";
const DETECTABLES_TTL_MS = 86_400_000;
let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null;
export function _resetDetectablesCache(): void {
detectablesCache = null;
}
export async function getDetectables(): Promise<DetectableEntry[]> {
if (detectablesCache && Date.now() < detectablesCache.expires) {
return detectablesCache.data;
}
try {
const response = await fetch(DETECTABLES_URL);
if (!response.ok) {
warnDev(`detectables failed: ${response.status}`);
return detectablesCache?.data ?? [];
}
const data = (await response.json()) as DetectableEntry[];
detectablesCache = { data, expires: Date.now() + DETECTABLES_TTL_MS };
return data;
} catch (error) {
warnDev(`detectables request failed: ${error instanceof Error ? error.message : error}`);
return detectablesCache?.data ?? [];
}
}
export function steamIdFromEntry(entry: DetectableEntry | undefined): string | null {
const sku = entry?.third_party_skus?.find(
(s) => s.distributor === "steam" && /^\d+$/.test(s.id),
);
return sku?.id ?? null;
}
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getDiscordAppInfo } from "@/lib/game/discord-app";
afterEach(() => {
vi.unstubAllGlobals();
});
function okRpc(overrides = {}) {
return {
ok: true,
json: async () => ({ id: "123", name: "Game", ...overrides }),
} as Response;
}
describe("getDiscordAppInfo", () => {
it("returns null for non-numeric ids without fetching", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getDiscordAppInfo("abc")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("maps a successful RPC response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => okRpc()),
);
const info = await getDiscordAppInfo("123");
expect(info?.gameUrl).toBe("https://discord.com/games/123");
});
it("returns null on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 404 }) as Response),
);
await expect(getDiscordAppInfo("123")).resolves.toBeNull();
});
it("throws on network failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getDiscordAppInfo("123")).rejects.toThrow("discord rpc unreachable");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { warnDev } from "@/lib/log";
export function discordAppIconUrl(
applicationId: string,
iconHash: string | null,
size = 128,
): string | null {
if (!iconHash) {
return null;
}
const extension = iconHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
}
export function discordGameUrl(gameId: string): string {
return `https://discord.com/games/${gameId}`;
}
export function discordAppCoverUrl(
applicationId: string,
coverHash: string | null,
size = 512,
): string | null {
if (!coverHash) {
return null;
}
return `https://cdn.discordapp.com/app-icons/${applicationId}/${coverHash}.png?size=${size}`;
}
export function steamdbAppUrl(steamId: string): string {
return `https://steamdb.info/app/${steamId}/`;
}
export interface DiscordAppInfo {
id: string;
name: string;
icon: string | null;
iconUrl: string | null;
coverImage: string | null;
coverUrl: string | null;
gameId: string;
gameUrl: string;
}
interface DiscordRpcResponse {
id?: string;
name?: string;
icon?: string | null;
cover_image?: string | null;
linked_games?: Array<{ id: string }> | null;
}
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
let response: Response;
try {
response = await fetch(`https://discord.com/api/v10/applications/${applicationId}/rpc`, {
headers: { "User-Agent": "fristy.net (personal site)" },
next: { revalidate: 86_400 },
});
} catch (error) {
throw new Error(`discord rpc unreachable: ${applicationId}`, { cause: error });
}
if (!response.ok) {
if (response.status === 404) {
return null;
}
if (response.status >= 500 || response.status === 429) {
throw new Error(`discord rpc upstream failed: ${applicationId} -> ${response.status}`, {
cause: new Error(`HTTP ${response.status}`),
});
}
warnDev(`discord app failed: ${response.status}`);
return null;
}
let json: DiscordRpcResponse;
try {
json = (await response.json()) as DiscordRpcResponse;
} catch (error) {
throw new Error(`discord rpc malformed response: ${applicationId}`, { cause: error });
}
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
return {
id: json.id ?? applicationId,
name: json.name ?? "",
icon: json.icon ?? null,
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
coverImage: json.cover_image ?? null,
coverUrl: discordAppCoverUrl(applicationId, json.cover_image ?? null),
gameId,
gameUrl: discordGameUrl(gameId),
};
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import type { GamePricePoint } from "@/lib/game/game-profile-types";
import {
describeProfile,
discountPercent,
formatCount,
formatElapsed,
formatPrice,
formatReleaseDate,
localizeScore,
normalizeGameTitle,
resolveGameLocale,
selectGameView,
type ModalDict,
} from "@/lib/game/game-format";
const dict = {
reviewScores: {
overwhelminglyPositive: "Son Derece Olumlu",
positive: "Olumlu",
mixed: "Karışık",
},
} as unknown as ModalDict;
const point: GamePricePoint = {
price: 100,
regular: 200,
currency: "USD",
shop: "Steam",
url: "https://example.com",
};
describe("localizeScore", () => {
it("maps known Steam labels", () => {
expect(localizeScore("Overwhelmingly Positive", dict)).toBe("Son Derece Olumlu");
});
it("falls back to the raw label", () => {
expect(localizeScore("Unknown Label", dict)).toBe("Unknown Label");
});
});
describe("discountPercent", () => {
it("computes the discount", () => {
expect(discountPercent(point)).toBe(50);
});
it("returns null without a real discount", () => {
expect(discountPercent({ ...point, regular: 100 })).toBeNull();
expect(discountPercent({ ...point, regular: 0 })).toBeNull();
});
});
describe("formatCount", () => {
it("formats with unit", () => {
expect(formatCount(3, "en-US", "reviews")).toBe("3 reviews");
});
it("renders a dash for nullish values", () => {
expect(formatCount(null, "en-US", "reviews")).toBe("—");
expect(formatCount(undefined, "en-US", "reviews")).toBe("—");
});
});
describe("formatPrice", () => {
it("formats currency", () => {
expect(formatPrice(point, "en-US")).toContain("100");
});
});
describe("formatReleaseDate", () => {
it("localizes ISO dates", () => {
expect(formatReleaseDate("2024-01-15", "en-US")).toContain("2024");
});
it("passes through non-ISO values", () => {
expect(formatReleaseDate("soon", "en-US")).toBe("soon");
});
});
describe("normalizeGameTitle", () => {
it("lowercases and strips marks", () => {
expect(normalizeGameTitle("Elden Ring™")).toBe("elden ring");
});
it("collapses separators", () => {
expect(normalizeGameTitle("Half-Life 2: Episode One")).toBe("half life 2 episode one");
});
});
describe("resolveGameLocale", () => {
it("resolves supported locales", () => {
expect(resolveGameLocale("tr")).toEqual({
lang: "tr",
country: "TR",
localeTag: "tr-TR",
steamLang: "turkish",
});
expect(resolveGameLocale("de").country).toBe("DE");
});
it("falls back to English", () => {
expect(resolveGameLocale("fr").lang).toBe("en");
expect(resolveGameLocale("").localeTag).toBe("en-US");
});
});
describe("selectGameView", () => {
it("falls back without a profile", () => {
expect(selectGameView(null, "Name", "https://fallback")).toEqual({
gameUrl: "https://fallback",
name: "Name",
bestPrice: null,
historyLow: null,
isFree: false,
});
});
});
describe("formatElapsed", () => {
it("formats per-locale units from one table", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
try {
const start = new Date("2024-01-15T10:30:00Z").getTime();
expect(formatElapsed(start, "tr")).toBe("1s 30dk");
expect(formatElapsed(start, "de")).toBe("1 Std. 30 Min.");
expect(formatElapsed(start, "en")).toBe("1h 30m");
expect(formatElapsed(start, "fr")).toBe("1h 30m");
expect(formatElapsed(Date.now(), "tr")).toBe("<1dk");
} finally {
vi.useRealTimers();
}
});
});
describe("describeProfile", () => {
it("handles a null profile", () => {
const summary = describeProfile(null);
expect(summary).toEqual({
dbRating: null,
itadReviews: [],
hasDetails: false,
hasReviews: false,
metascore: null,
opencritic: null,
});
});
});
+215
View File
@@ -0,0 +1,215 @@
import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
export type ModalDict = Dictionary["discord"]["gameModal"];
export const STEAM_SCORE_KEYS: Partial<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",
};
export const LOCALE_TAGS: Record<Locale, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
export const COUNTRIES: Record<Locale, string> = {
tr: "TR",
en: "US",
de: "DE",
};
export function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
export function formatRatingPercent(value: number, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
export function formatPrice(point: GamePricePoint, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
export function formatReleaseDate(value: string, localeTag: 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(localeTag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
export function formatCount(
value: number | null | undefined,
localeTag: string,
unit: string,
): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(localeTag)} ${unit}`;
}
export 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 steamdbRating(positive: number, total: number): number | null {
if (
!Number.isFinite(positive) ||
!Number.isFinite(total) ||
total <= 0 ||
positive < 0 ||
positive > total
) {
return null;
}
const score = positive / total;
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
}
export function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export interface GameView {
gameUrl: string;
name: string;
bestPrice: GamePricePoint | null;
historyLow: GamePricePoint | null;
isFree: boolean;
}
export function selectGameView(
profile: GameProfile | null,
fallbackName: string,
fallbackGameUrl: string,
): GameView {
const bestPrice = profile?.itad?.prices ?? null;
const historyLow = profile?.itad?.historyLow ?? null;
return {
gameUrl: profile?.gameUrl ?? fallbackGameUrl,
name: profile?.name || fallbackName,
bestPrice,
historyLow,
isFree: !!bestPrice && bestPrice.price === 0,
};
}
export interface ProfileSummary {
dbRating: number | null;
itadReviews: ItadReview[];
hasDetails: boolean;
hasReviews: boolean;
metascore: ItadReview | null;
opencritic: ItadReview | null;
}
export function describeProfile(profile: GameProfile | null): ProfileSummary {
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 || metascore || opencritic);
return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic };
}
const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" };
const ELAPSED_UNITS: Partial<
Record<string, { hour: string; minute: string; lessThanMinute: string }>
> = {
tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" },
de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." },
en: DEFAULT_ELAPSED_UNITS,
};
export function formatElapsed(start: number, lang: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS;
if (hours > 0) {
return `${hours}${units.hour} ${minutes}${units.minute}`;
}
if (minutes > 0) {
return `${minutes}${units.minute}`;
}
return units.lessThanMinute;
}
export function normalizeGameTitle(value: string): string {
return value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export interface GameLocale {
lang: Locale;
country: string;
localeTag: string;
steamLang: string;
}
const STEAM_LANG: Record<Locale, string> = {
tr: "turkish",
en: "english",
de: "german",
};
export function resolveGameLocale(lang: string): GameLocale {
const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en";
return {
lang: resolved,
country: COUNTRIES[resolved],
localeTag: LOCALE_TAGS[resolved],
steamLang: STEAM_LANG[resolved],
};
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getGameProfile } from "@/lib/game/game-profile";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mockFetch(handler: (url: string, init?: RequestInit) => unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => handler(url, init)),
);
}
function ok(data: unknown) {
return { ok: true, json: async () => data };
}
function baseEnv() {
vi.stubGlobal("process", { ...process, env: {} });
}
describe("getGameProfile", () => {
it("rejects non-numeric ids without fetching", async () => {
baseEnv();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getGameProfile("abc")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("assembles a profile from Steam when providers are absent", async () => {
baseEnv();
mockFetch((url) => {
if (url.includes("discord.com")) {
return ok({ id: "1", name: "Game" });
}
if (url.includes("detectables")) {
return ok([
{
id: "1",
name: "Game",
third_party_skus: [{ distributor: "steam", id: "100" }],
},
]);
}
if (url.includes("appdetails")) {
return ok({
"100": {
success: true,
data: {
name: "Game",
developers: ["Dev"],
publishers: ["Pub"],
platforms: { windows: true, mac: false, linux: false },
},
},
});
}
if (url.includes("appreviews")) {
return ok({
query_summary: {
review_score_desc: "Positive",
total_positive: 8,
total_negative: 2,
total_reviews: 10,
},
});
}
throw new Error(`unexpected fetch: ${url}`);
});
const profile = await getGameProfile("1", "en", "US");
expect(profile?.name).toBe("Game");
expect(profile?.developers).toEqual(["Dev"]);
expect(profile?.platforms).toContain("Windows");
expect(profile?.sources).toEqual({ igdb: false, steam: true, itad: false });
expect(profile?.itad).toBeNull();
});
it("returns null when Discord RPC is missing", async () => {
baseEnv();
mockFetch((url) => {
if (url.includes("discord.com")) {
return { ok: false, status: 404 };
}
throw new Error(`unexpected fetch: ${url}`);
});
await expect(getGameProfile("1")).resolves.toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
export interface GameExternalLink {
label: string;
url: string;
}
export interface GameReviewSummary {
label: string;
total: number | null;
positive: number | null;
negative: number | null;
url: string;
}
export interface GamePricePoint {
price: number;
regular: number;
currency: string;
shop: string;
url: string;
}
export interface ItadReview {
score: number | null;
source: string;
count: number | null;
url: string;
}
export interface GameProfile {
applicationId: string;
gameId: string;
gameUrl: string;
name: string;
genres: string[];
description: string | null;
developers: string[];
publishers: string[];
releaseDate: string | null;
platforms: string[];
links: GameExternalLink[];
coverUrl: string | null;
artworkUrl: string | null;
screenshots: string[];
steamId: string | null;
steamUrl: string | null;
igdbUrl: string | null;
steamReviews: GameReviewSummary | null;
itad: {
prices: GamePricePoint | null;
historyLow: GamePricePoint | null;
pageUrl: string | null;
playerCount: number | null;
reviews: ItadReview[];
} | null;
sources: {
igdb: boolean;
steam: boolean;
itad: boolean;
};
}
export interface DetectableEntry {
id: string;
name: string;
third_party_skus?: Array<{ distributor: string; id: string }>;
}
export interface SteamAppDetails {
success: boolean;
data?: {
name?: string;
short_description?: string;
header_image?: string;
genres?: Array<{ description?: string }>;
developers?: string[];
publishers?: string[];
platforms?: { windows?: boolean; mac?: boolean; linux?: boolean };
release_date?: { date?: string };
screenshots?: Array<{ path_full?: string }>;
};
}
export interface SteamReviewQuery {
review_score_desc?: string;
total_positive?: number;
total_negative?: number;
total_reviews?: number;
}
export interface IgdbGame {
name?: string;
slug?: string;
genres?: Array<{ name?: string }>;
summary?: string;
storyline?: string;
cover?: { url?: string };
artworks?: Array<{ url?: string }>;
screenshots?: Array<{ url?: string }>;
websites?: Array<{ url?: string; category?: number }>;
involved_companies?: Array<{
developer?: boolean;
publisher?: boolean;
company?: { name?: string };
}>;
platforms?: Array<{ abbreviation?: string; name?: string }>;
first_release_date?: number;
}
export interface ItadGameInfo {
title?: string;
assets?: { boxart?: string; banner600?: string };
tags?: string[];
releaseDate?: string | null;
developers?: Array<{ name?: string }>;
publishers?: Array<{ name?: string }>;
reviews?: Array<{
score?: number | null;
source?: string;
count?: number | null;
url?: string;
}>;
players?: { recent?: number } | null;
urls?: { game?: string };
}
export interface ItadOverviewPrice {
current?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
url?: string;
} | null;
lowest?: {
shop?: { name?: string };
price?: { amount?: number; currency?: string };
regular?: { amount?: number };
cut?: number;
url?: string;
} | null;
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
buildLinks,
mergePeople,
mergePlatforms,
selectScreenshots,
} from "@/lib/game/game-profile";
import type { IgdbGame } from "@/lib/game/game-profile-types";
describe("mergePeople", () => {
it("prefers IGDB companies when present", () => {
const igdb = {
involved_companies: [
{ developer: true, company: { name: "Dev Studio" } },
{ publisher: true, company: { name: "Pub Inc" } },
],
} as IgdbGame;
const { developers, publishers } = mergePeople(igdb, null, null);
expect(developers).toEqual(["Dev Studio"]);
expect(publishers).toEqual(["Pub Inc"]);
});
it("falls back to Steam and ITAD lists", () => {
const steam = { developers: ["Valve"], publishers: ["Valve"] };
const { developers, publishers } = mergePeople(null, steam, null);
expect(developers).toEqual(["Valve"]);
expect(publishers).toEqual(["Valve"]);
});
it("returns empty lists without sources", () => {
expect(mergePeople(null, null, null)).toEqual({ developers: [], publishers: [] });
});
});
describe("mergePlatforms", () => {
it("normalizes IGDB platform names", () => {
const igdb = {
platforms: [{ name: "PC (Microsoft Windows)" }, { name: "Mac" }, { name: "Switch" }],
} as IgdbGame;
expect(mergePlatforms(igdb, null, undefined)).toEqual(["Windows", "MacOS", "Switch"]);
});
it("merges IGDB, Steam, and SKU platforms without early exit", () => {
const igdb = { platforms: [{ name: "Switch" }] } as IgdbGame;
const steam = { platforms: { windows: true, mac: false, linux: false } };
const entry = { id: "g", name: "G", third_party_skus: [{ distributor: "xbox", id: "1" }] };
expect(mergePlatforms(igdb, steam, entry)).toEqual(["Switch", "Windows", "Xbox"]);
});
it("still resolves SKUs when Steam data is missing", () => {
const entry = {
id: "g",
name: "G",
third_party_skus: [{ distributor: "playstation", id: "1" }],
};
expect(mergePlatforms(null, null, entry)).toEqual(["PlayStation"]);
});
});
describe("buildLinks", () => {
it("labels known categories and dedupes", () => {
const igdb = {
websites: [
{ url: "https://store.steampowered.com/app/1", category: 13 },
{ url: "https://store.steampowered.com/app/1", category: 13 },
{ url: "example.com", category: 99 },
],
} as IgdbGame;
const links = buildLinks(igdb);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({
label: "Steam",
url: "https://store.steampowered.com/app/1",
});
expect(links[1].label).toBe("example.com");
});
it("returns no links without websites", () => {
expect(buildLinks(null)).toEqual([]);
});
});
describe("selectScreenshots", () => {
it("prefers IGDB shots and caps at 8", () => {
const igdb = {
screenshots: Array.from({ length: 10 }, (_, i) => ({
url: `//images.igdb.com/t_thumb/${i}.jpg`,
})),
} as IgdbGame;
const shots = selectScreenshots(igdb, null);
expect(shots).toHaveLength(8);
expect(shots[0]).toContain("t_screenshot_big");
});
it("falls back to Steam shots", () => {
const steam = { screenshots: [{ path_full: "https://cdn/shot.jpg" }] };
expect(selectScreenshots(null, steam)).toEqual(["https://cdn/shot.jpg"]);
});
});
+286
View File
@@ -0,0 +1,286 @@
import { getDiscordAppInfo } from "@/lib/game/discord-app";
import { getDetectables, steamIdFromEntry } from "@/lib/game/detectables";
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
import { getIgdbGame, igdbImage } from "@/lib/game/igdb";
import { getItadId, itadFetch } from "@/lib/game/itad";
import { toIsoDate } from "@/lib/game/game-format";
import type {
GameExternalLink,
GamePricePoint,
GameProfile,
IgdbGame,
ItadGameInfo,
ItadOverviewPrice,
ItadReview,
SteamAppDetails,
} from "@/lib/game/game-profile-types";
import type { DetectableEntry } from "@/lib/game/game-profile-types";
const DISTRIBUTOR_PLATFORM: Partial<Record<string, string>> = {
xbox: "Xbox",
xbox_title: "Xbox",
xbox_game_pass: "Xbox",
playstation: "PlayStation",
nintendo: "Switch",
};
const IGDB_WEBSITE_LABELS: Record<number, string> = {
1: "Website",
4: "Facebook",
5: "X",
6: "Twitch",
8: "Instagram",
9: "YouTube",
13: "Steam",
14: "Reddit",
16: "Epic",
17: "GOG",
18: "Discord",
};
type SteamData = SteamAppDetails["data"];
export function mergePeople(
igdb: IgdbGame | null,
steam: SteamData | null,
itadInfo: ItadGameInfo | null,
): { developers: string[]; publishers: string[] } {
const companies = igdb?.involved_companies ?? [];
if (companies.length === 0) {
const steamDevs = steam?.developers?.length
? steam.developers
: itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [];
const steamPubs = steam?.publishers?.length
? steam.publishers
: itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [];
return { developers: steamDevs, publishers: steamPubs };
}
const names = (developer: boolean): string[] => [
...new Set(
companies
.filter((c) => (developer ? c.developer : c.publisher))
.map((c) => c.company?.name)
.filter((n): n is string => !!n),
),
];
return { developers: names(true), publishers: names(false) };
}
export function mergePlatforms(
igdb: IgdbGame | null,
steam: SteamData | null,
entry: DetectableEntry | undefined,
): string[] {
const platforms = new Set<string>();
for (const p of igdb?.platforms ?? []) {
const raw = (p.name ?? "").trim();
if (!raw) {
continue;
}
if (raw === "PC (Microsoft Windows)") {
platforms.add("Windows");
} else if (raw === "Mac") {
platforms.add("MacOS");
} else {
platforms.add(raw);
}
}
if (steam?.platforms) {
if (steam.platforms.windows) {
platforms.add("Windows");
}
if (steam.platforms.mac) {
platforms.add("MacOS");
}
if (steam.platforms.linux) {
platforms.add("Linux");
}
}
for (const sku of entry?.third_party_skus ?? []) {
const mapped = DISTRIBUTOR_PLATFORM[sku.distributor];
if (mapped) {
platforms.add(mapped);
}
}
return [...platforms];
}
export function buildLinks(igdb: IgdbGame | null): GameExternalLink[] {
const links: GameExternalLink[] = [];
for (const site of igdb?.websites ?? []) {
if (!site.url) {
continue;
}
const url = site.url.startsWith("http") ? site.url : `https://${site.url}`;
let parsed: URL | null = null;
try {
parsed = new URL(url);
} catch {
continue;
}
if (parsed.protocol !== "https:") {
continue;
}
let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null;
if (!label) {
label = parsed.hostname.replace(/^www\./, "");
}
if (!links.some((l) => l.url === url)) {
links.push({ label, url });
}
}
return links;
}
export function selectScreenshots(igdb: IgdbGame | null, steam: SteamData | null): string[] {
return [
...(igdb?.screenshots ?? [])
.map((s) => igdbImage(s.url, "t_screenshot_big"))
.filter((u): u is string => !!u),
...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u),
].slice(0, 8);
}
export function buildGenres(igdb: IgdbGame | null, itadInfo: ItadGameInfo | null): string[] {
return [
...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n),
...(igdb?.genres?.some((g) => !!g.name) ? [] : (itadInfo?.tags ?? []).slice(0, 5)),
].slice(0, 6);
}
export function selectDescription(
lang: string,
igdb: IgdbGame | null,
steam: SteamData | null,
): string | null {
return lang === "en"
? (igdb?.summary ?? steam?.short_description ?? null)
: (steam?.short_description ?? igdb?.summary ?? null);
}
export function selectArtwork(
igdb: IgdbGame | null,
steam: SteamData | null,
itadInfo: ItadGameInfo | null,
): string | null {
return (
igdbImage(igdb?.cover?.url, "t_cover_big") ??
steam?.header_image ??
itadInfo?.assets?.banner600 ??
null
);
}
export function toPricePoint(p: ItadOverviewPrice["current"]): GamePricePoint | null {
if (!p?.price || typeof p.price.amount !== "number") {
return null;
}
return {
price: p.price.amount,
regular: p.regular?.amount ?? p.price.amount,
currency: p.price.currency ?? "",
shop: p.shop?.name ?? "",
url: p.url?.startsWith("https://") ? p.url : "",
};
}
export function mapItadBlock(
itadInfo: ItadGameInfo | null,
priceEntry:
| { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] }
| undefined,
): NonNullable<GameProfile["itad"]> | null {
if (!process.env.ITAD_API_KEY || (!itadInfo && !priceEntry)) {
return null;
}
return {
prices: toPricePoint(priceEntry?.current),
historyLow: toPricePoint(priceEntry?.lowest),
pageUrl: itadInfo?.urls?.game ?? null,
playerCount: itadInfo?.players?.recent ?? null,
reviews: (itadInfo?.reviews ?? [])
.filter((r): r is ItadReview & { source: string; url: string } => !!r.source && !!r.url)
.map((r) => ({
score: r.score ?? null,
source: r.source,
count: r.count ?? null,
url: r.url,
})),
};
}
export async function getGameProfile(
applicationId: string,
lang = "en",
country = "TR",
): Promise<GameProfile | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
const appInfo = await getDiscordAppInfo(applicationId);
if (!appInfo) {
return null;
}
const detectables = await getDetectables();
const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId);
const steamId = steamIdFromEntry(entry);
const [steam, steamReviews, igdb] = await Promise.all([
steamId ? getSteamDetails(steamId, lang, country) : Promise.resolve(null),
steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null),
getIgdbGame(appInfo.name || entry?.name || "", steamId),
]);
const itadId = process.env.ITAD_API_KEY
? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name)
: null;
const [itadInfo, itadOverview] = itadId
? await Promise.all([
itadFetch<ItadGameInfo>(`/games/info/v2?id=${itadId}`),
itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([itadId]),
}),
])
: [null, null];
const { developers, publishers } = mergePeople(igdb, steam, itadInfo);
const platforms = mergePlatforms(igdb, steam, entry);
const links = buildLinks(igdb);
const screenshots = selectScreenshots(igdb, steam);
const priceEntry = itadOverview?.prices?.[0];
return {
applicationId,
gameId: appInfo.gameId,
gameUrl: appInfo.gameUrl,
name: igdb?.name ?? steam?.name ?? appInfo.name,
genres: buildGenres(igdb, itadInfo),
description: selectDescription(lang, igdb, steam),
developers,
publishers,
releaseDate:
toIsoDate(igdb?.first_release_date) ??
itadInfo?.releaseDate ??
steam?.release_date?.date ??
null,
platforms,
links,
coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"),
artworkUrl: selectArtwork(igdb, steam, itadInfo),
screenshots,
steamId,
steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null,
igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null,
steamReviews,
itad: mapItadBlock(itadInfo, priceEntry),
sources: {
igdb: !!igdb,
steam: !!steam,
itad: !!(itadInfo || priceEntry),
},
};
}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetIgdbState, getIgdbGame, igdbImage } from "@/lib/game/igdb";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetIgdbState();
});
function tokenResponse() {
return {
ok: true,
json: async () => ({ access_token: "tok", expires_in: 3600 }),
} as Response;
}
describe("igdbImage", () => {
it("upgrades thumbnail sizes", () => {
expect(igdbImage("//images.igdb.com/t_thumb/1.jpg", "t_cover_big")).toBe(
"https://images.igdb.com/t_cover_big/1.jpg",
);
});
it("returns null without a url", () => {
expect(igdbImage(undefined, "t_cover_big")).toBeNull();
});
});
describe("getIgdbGame", () => {
it("returns null without credentials and warns once", async () => {
vi.stubGlobal("process", { ...process, env: {} });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns null when the token fetch rejects", async () => {
vi.stubGlobal("process", {
...process,
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getIgdbGame("Game", null)).resolves.toBeNull();
});
it("escapes backslashes and quotes in search queries", async () => {
vi.stubGlobal("process", {
...process,
env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" },
});
const bodies: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (typeof init?.body === "string" && init.body.includes("external_games")) {
return { ok: true, json: async () => [] } as Response;
}
if (typeof init?.body === "string") {
bodies.push(init.body);
return { ok: true, json: async () => [] } as Response;
}
return tokenResponse();
}),
);
await expect(getIgdbGame('a\\b"c', null)).resolves.toBeNull();
expect(bodies.some((b) => b.includes('a\\\\b\\"c'))).toBe(true);
});
});
+139
View File
@@ -0,0 +1,139 @@
import type { IgdbGame } from "@/lib/game/game-profile-types";
import { normalizeGameTitle } from "@/lib/game/game-format";
import { warnDev } from "@/lib/log";
const IGDB_GAME_FIELDS =
"name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," +
"websites.url,websites.category,involved_companies.developer," +
"involved_companies.publisher,involved_companies.company.name," +
"platforms.abbreviation,platforms.name,first_release_date;";
const TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token";
const TOKEN_FALLBACK_EXPIRY_S = 3600;
const MS_PER_SECOND = 1000;
const TOKEN_EXPIRY_SKEW_MS = 60_000;
let igdbTokenCache: { token: string; expires: number } | null = null;
let igdbKeyWarned = false;
export function _resetIgdbState(): void {
igdbTokenCache = null;
igdbKeyWarned = false;
}
async function getIgdbToken(): Promise<string | null> {
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
if (!igdbKeyWarned) {
igdbKeyWarned = true;
warnDev("igdb not configured");
}
return null;
}
if (igdbTokenCache && Date.now() < igdbTokenCache.expires) {
return igdbTokenCache.token;
}
try {
const response = await fetch(TWITCH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "client_credentials",
}),
});
if (!response.ok) {
warnDev(`igdb token failed: ${response.status}`);
return null;
}
const json = (await response.json()) as { access_token?: string; expires_in?: number };
if (!json.access_token) {
return null;
}
igdbTokenCache = {
token: json.access_token,
expires:
Date.now() +
(json.expires_in ?? TOKEN_FALLBACK_EXPIRY_S) * MS_PER_SECOND -
TOKEN_EXPIRY_SKEW_MS,
};
return json.access_token;
} catch (error) {
warnDev(`igdb token request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
async function igdbQuery<T>(token: string, endpoint: string, body: string): Promise<T | null> {
try {
const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
headers: {
"Client-ID": process.env.TWITCH_CLIENT_ID ?? "",
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body,
next: { revalidate: 86_400 },
});
if (!response.ok) {
warnDev(`igdb query failed: ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
warnDev(`igdb query request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export function igdbImage(url: string | undefined, size: string): string | null {
if (!url) {
return null;
}
const full = url.startsWith("//") ? `https:${url}` : url;
return full.replace(/t_thumb|t_cover_small|t_logo_med/, size);
}
export async function getIgdbGame(name: string, steamId: string | null): Promise<IgdbGame | null> {
const token = await getIgdbToken();
if (!token) {
return null;
}
if (steamId) {
const external = await igdbQuery<Array<{ game?: number }>>(
token,
"external_games",
`fields game; where uid = "${steamId}"; limit 1;`,
);
const gameId = external?.[0]?.game;
if (gameId) {
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`,
);
if (games?.[0]) {
return games[0];
}
}
}
const cleaned = name
.replace(/[®™©]/g, "")
.replace(/\s{2,}/g, " ")
.trim();
const escaped = cleaned.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const games = await igdbQuery<IgdbGame[]>(
token,
"games",
`fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`,
);
if (!games || games.length === 0) {
return null;
}
const wanted = normalizeGameTitle(name);
return games.find((g) => g.name && normalizeGameTitle(g.name) === wanted) ?? games[0] ?? null;
}
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _resetItadWarning, getItadId, itadFetch } from "@/lib/game/itad";
import { toIsoDate } from "@/lib/game/game-format";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
_resetItadWarning();
});
describe("toIsoDate", () => {
it("converts unix seconds to ISO dates", () => {
expect(toIsoDate(1705276800)).toBe("2024-01-15");
});
it("returns null for missing values", () => {
expect(toIsoDate(undefined)).toBeNull();
expect(toIsoDate(0)).toBeNull();
});
});
describe("itadFetch", () => {
it("returns null without an API key and without fetching", async () => {
vi.stubGlobal("process", { ...process, env: {} });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns data on success and null on HTTP error", async () => {
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ id: "1" }) }) as Response),
);
await expect(itadFetch("/games/info/v2")).resolves.toEqual({ id: "1" });
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 429 }) as Response),
);
await expect(itadFetch("/games/info/v2")).resolves.toBeNull();
});
});
describe("getItadId", () => {
it("matches normalized titles", async () => {
vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } });
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => [{ id: "7", title: "Elden Ring" }],
}) as Response,
),
);
await expect(getItadId("ELDEN RING")).resolves.toBe("7");
await expect(getItadId("Unknown")).resolves.toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { normalizeGameTitle } from "@/lib/game/game-format";
import { warnDev } from "@/lib/log";
let itadKeyWarned = false;
export function _resetItadWarning(): void {
itadKeyWarned = false;
}
export async function itadFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
const key = process.env.ITAD_API_KEY;
if (!key) {
if (!itadKeyWarned) {
itadKeyWarned = true;
warnDev("itad not configured");
}
return null;
}
try {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(
`https://api.isthereanydeal.com${path}${separator}key=${key}`,
{
...init,
next: { revalidate: 3600 },
},
);
if (!response.ok) {
warnDev(`itad failed: ${response.status}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
warnDev(`itad request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export async function getItadId(title: string): Promise<string | null> {
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
`/games/search/v1?title=${encodeURIComponent(title)}&results=5`,
);
if (!results || results.length === 0) {
return null;
}
const wanted = normalizeGameTitle(title);
return results.find((r) => r.title && normalizeGameTitle(r.title) === wanted)?.id ?? null;
}
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
activityImageUrl,
discordAvatarUrl,
formatSpotifyList,
getLanyard,
spotifyTrackUrl,
type LanyardActivity,
} from "@/lib/game/lanyard";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function activity(overrides: Partial<LanyardActivity> = {}): LanyardActivity {
return { id: "1", name: "Game", type: 0, ...overrides };
}
describe("activityImageUrl", () => {
it("resolves external media keys", () => {
expect(activityImageUrl(activity({ assets: { large_image: "mp:abc" } }), "large")).toBe(
"https://media.discordapp.net/external/abc",
);
});
it("resolves Spotify keys", () => {
expect(
activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large"),
).toBe("https://i.scdn.co/image/img");
});
it("resolves app assets with application id", () => {
expect(
activityImageUrl(
activity({ application_id: "9", assets: { large_image: "art" } }),
"large",
),
).toBe("https://cdn.discordapp.com/app-assets/9/art.png");
});
it("returns null without a key", () => {
expect(activityImageUrl(activity(), "large")).toBeNull();
});
it("picks small vs large art", () => {
const withBoth = activity({
assets: { large_image: "mp:big", small_image: "mp:small" },
});
expect(activityImageUrl(withBoth, "small")).toContain("small");
expect(activityImageUrl(withBoth, "large")).toContain("big");
});
});
describe("formatSpotifyList", () => {
it("normalizes separators", () => {
expect(formatSpotifyList("a;b, ,c")).toBe("a,b,c");
expect(formatSpotifyList("a b")).toBe("a b");
});
it("returns empty for nullish input", () => {
expect(formatSpotifyList(null)).toBe("");
expect(formatSpotifyList(undefined)).toBe("");
});
});
describe("spotifyTrackUrl", () => {
it("builds the track URL", () => {
expect(spotifyTrackUrl("xyz")).toBe("https://open.spotify.com/track/xyz");
});
});
describe("discordAvatarUrl", () => {
it("falls back to the default avatar", () => {
expect(discordAvatarUrl("1", null)).toContain("embed/avatars/0.png");
});
it("picks gif for animated hashes", () => {
expect(discordAvatarUrl("1", "a_hash")).toContain(".gif");
expect(discordAvatarUrl("1", "hash")).toContain(".png");
});
});
describe("getLanyard", () => {
it("returns data on success and null otherwise", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: {} }) })),
);
await expect(getLanyard()).resolves.toEqual({});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ success: false }) })),
);
await expect(getLanyard()).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getLanyard()).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getLanyard()).resolves.toBeNull();
});
});
+13 -77
View File
@@ -1,6 +1,8 @@
import { warnDev } from "@/lib/log";
export const DISCORD_ID = "1014549805608992901";
const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export const LANYARD_WS = "wss://api.lanyard.rest/socket";
@@ -31,8 +33,13 @@ export interface LanyardActivity {
assets?: LanyardActivityAssets | null;
}
export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null {
const key = small ? activity.assets?.small_image : activity.assets?.large_image;
export type ActivityImageSize = "small" | "large";
export function activityImageUrl(
activity: LanyardActivity,
size: ActivityImageSize = "large",
): string | null {
const key = size === "small" ? activity.assets?.small_image : activity.assets?.large_image;
if (!key) {
return null;
}
@@ -75,75 +82,6 @@ export function spotifyTrackUrl(trackId: string): string {
return `https://open.spotify.com/track/${trackId}`;
}
export function discordAppIconUrl(
applicationId: string,
iconHash: string | null,
size = 128,
): string | null {
if (!iconHash) {
return null;
}
const extension = iconHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`;
}
export function discordGameUrl(gameId: string): string {
return `https://discord.com/games/${gameId}`;
}
export interface DiscordAppInfo {
id: string;
name: string;
icon: string | null;
iconUrl: string | null;
coverImage: string | null;
coverUrl: string | null;
gameId: string;
gameUrl: string;
}
interface DiscordRpcResponse {
id: string;
name: string;
icon?: string | null;
cover_image?: string | null;
linked_games?: Array<{ id: string }> | null;
}
export async function getDiscordAppInfo(applicationId: string): Promise<DiscordAppInfo | null> {
if (!/^\d+$/.test(applicationId)) {
return null;
}
try {
const response = await fetch(
`https://discord.com/api/v10/applications/${applicationId}/rpc`,
{
headers: { "User-Agent": "fristy.net (personal site)" },
next: { revalidate: 86_400 },
},
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as DiscordRpcResponse;
const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId;
return {
id: json.id ?? applicationId,
name: json.name ?? "",
icon: json.icon ?? null,
iconUrl: discordAppIconUrl(applicationId, json.icon ?? null),
coverImage: json.cover_image ?? null,
coverUrl: json.cover_image
? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512`
: null,
gameId,
gameUrl: discordGameUrl(gameId),
};
} catch {
return null;
}
}
export interface LanyardData {
discord_user: {
id: string;
@@ -172,19 +110,17 @@ export async function getLanyard(): Promise<LanyardData | null> {
try {
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
if (!response.ok) {
warnDev(`lanyard failed: ${response.status}`);
return null;
}
const json = (await response.json()) as LanyardResponse;
return json.success ? json.data : null;
} catch {
} catch (error) {
warnDev(`lanyard request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export function lanyardUrl(): string {
return LANYARD_API;
}
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
if (!avatarHash) {
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam";
import { steamdbRating } from "@/lib/game/game-format";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("steamdbRating", () => {
it("returns null for invalid input", () => {
expect(steamdbRating(5, 0)).toBeNull();
expect(steamdbRating(-1, 10)).toBeNull();
expect(steamdbRating(11, 10)).toBeNull();
expect(steamdbRating(Number.NaN, 10)).toBeNull();
});
it("pulls low-count ratios toward 50%", () => {
const rating = steamdbRating(9, 10);
expect(rating).not.toBeNull();
expect(rating as number).toBeLessThan(0.9);
expect(rating as number).toBeGreaterThan(0.5);
});
it("converges to the raw ratio with many reviews", () => {
const rating = steamdbRating(900000, 1000000);
expect(rating).not.toBeNull();
expect(Math.abs((rating as number) - 0.9)).toBeLessThan(0.01);
});
it("rates unanimous positives below 100%", () => {
const rating = steamdbRating(10, 10);
expect(rating).not.toBeNull();
expect(rating as number).toBeLessThan(1);
});
});
describe("getSteamDetails", () => {
it("returns entry data on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => ({ "100": { success: true, data: { name: "Game" } } }),
}) as Response,
),
);
await expect(getSteamDetails("100")).resolves.toEqual({ name: "Game" });
});
it("returns null when fetch rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
});
it("returns null on failed entries and HTTP errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ "100": { success: false } }) })),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 }) as Response),
);
await expect(getSteamDetails("100")).resolves.toBeNull();
});
});
describe("getSteamReviewSummary", () => {
it("maps the query summary", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
({
ok: true,
json: async () => ({
query_summary: {
review_score_desc: "Positive",
total_positive: 9,
total_negative: 1,
total_reviews: 10,
},
}),
}) as Response,
),
);
const summary = await getSteamReviewSummary("100");
expect(summary?.total).toBe(10);
expect(summary?.url).toContain("100");
});
it("returns null without a numeric total", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => ({ query_summary: {} }) })),
);
await expect(getSteamReviewSummary("100")).resolves.toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import type {
GameReviewSummary,
SteamAppDetails,
SteamReviewQuery,
} from "@/lib/game/game-profile-types";
import { resolveGameLocale } from "@/lib/game/game-format";
import { warnDev } from "@/lib/log";
export async function getSteamDetails(
steamId: string,
lang = "en",
country = "US",
): Promise<SteamAppDetails["data"] | null> {
try {
const { steamLang } = resolveGameLocale(lang);
const response = await fetch(
`https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=${country}`,
{ next: { revalidate: 86_400 } },
);
if (!response.ok) {
warnDev(`steam details failed: ${response.status}`);
return null;
}
const json = (await response.json()) as Record<string, SteamAppDetails>;
const entry = json[steamId];
return entry?.success ? (entry.data ?? null) : null;
} catch (error) {
warnDev(`steam details request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
export async function getSteamReviewSummary(steamId: string): Promise<GameReviewSummary | null> {
try {
const url =
`https://store.steampowered.com/appreviews/${steamId}` +
"?json=1&language=all&purchase_type=all&num_per_page=0";
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) {
warnDev(`steam reviews failed: ${response.status}`);
return null;
}
const json = (await response.json()) as { query_summary?: SteamReviewQuery };
const summary = json.query_summary;
if (!summary || typeof summary.total_reviews !== "number") {
return null;
}
return {
label: summary.review_score_desc ?? "",
total: summary.total_reviews,
positive: summary.total_positive ?? null,
negative: summary.total_negative ?? null,
url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`,
};
} catch (error) {
warnDev(`steam reviews request failed: ${error instanceof Error ? error.message : error}`);
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getRepos } from "@/lib/gitea";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function apiRepo(overrides: Record<string, unknown>) {
return {
id: 0,
name: "repo",
full_name: "fristy/repo",
description: null,
language: null,
html_url: "https://git.fristy.net/fristy/repo",
website: "",
stars_count: 0,
forks_count: 0,
updated_at: "2024-01-01T00:00:00Z",
...overrides,
};
}
describe("getRepos", () => {
it("maps, filters, and sorts repos", async () => {
const payload = [
apiRepo({ id: 2, name: "old", full_name: "fristy/old", language: "TypeScript" }),
apiRepo({ id: 1, name: ".profile", updated_at: "2025-01-01T00:00:00Z" }),
apiRepo({
id: 3,
name: "new",
full_name: "fristy/new",
description: "fresh",
language: "Go",
website: "https://example.com",
stars_count: 5,
forks_count: 2,
updated_at: "2026-01-01T00:00:00Z",
}),
];
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => payload })),
);
const repos = await getRepos();
expect(repos.map((r) => r.name)).toEqual(["new", "old"]);
expect(repos[0]?.website).toBe("https://example.com");
expect(repos[1]?.website).toBeNull();
});
it("returns empty on HTTP error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 })),
);
await expect(getRepos()).resolves.toEqual([]);
});
});
+5 -3
View File
@@ -1,3 +1,5 @@
import { warnDev } from "@/lib/log";
const GITEA_API = "https://git.fristy.net/api/v1";
const GITEA_USER = "fristy";
@@ -36,7 +38,7 @@ export async function getRepos(): Promise<GiteaRepo[]> {
{ next: { revalidate: 3600 } },
);
if (!response.ok) {
console.error(`cannot fetch gitea repos: ${response.status}`);
warnDev(`cannot fetch gitea repos: ${response.status}`);
return [];
}
const data = (await response.json()) as GiteaApiRepo[];
@@ -55,8 +57,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
updatedAt: repo.updated_at,
}))
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
} catch (error) {
console.error("cannot fetch gitea repos:", error);
} catch {
warnDev("cannot fetch gitea repos");
return [];
}
}
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Spieldaten konnten nicht geladen werden.",
"retry": "Erneut versuchen",
"partialFail": "Einige Daten konnten nicht geladen werden.",
"noGameData": "Für diese App wurden keine Spieldaten gefunden.",
"screenshots": "Screenshots",
"reviewsUnit": "Bewertungen",
"scrollBack": "Zurück",
"scrollForward": "Weiter",
"totalReviews": "Bewertungen insgesamt",
"totalReviews": "Steam",
"steamdbRating": "SteamDB-Wertung",
"reviewScores": {
"overwhelminglyPositive": "Äußerst positiv",
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Couldn't load game info.",
"retry": "Retry",
"partialFail": "Some data failed to load.",
"noGameData": "No game data found for this app.",
"screenshots": "Screenshots",
"reviewsUnit": "reviews",
"scrollBack": "Back",
"scrollForward": "Forward",
"totalReviews": "Total Reviews",
"totalReviews": "Steam",
"steamdbRating": "SteamDB Rating",
"reviewScores": {
"overwhelminglyPositive": "Overwhelmingly Positive",
+2 -1
View File
@@ -49,11 +49,12 @@
"loadError": "Oyun bilgileri yüklenemedi.",
"retry": "Tekrar Dene",
"partialFail": "Bazı veriler yüklenemedi.",
"noGameData": "Bu uygulama için oyun verisi bulunamadı.",
"screenshots": "Görseller",
"reviewsUnit": "değerlendirme",
"scrollBack": "Geri",
"scrollForward": "İleri",
"totalReviews": "Toplam Değerlendirme",
"totalReviews": "Steam",
"steamdbRating": "SteamDB Puanı",
"reviewScores": {
"overwhelminglyPositive": "Son Derece Olumlu",
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { DEFAULT_LOCALE, matchLocale, type Locale } from "@/lib/i18n/locales";
describe("getDictionary", () => {
it("returns the requested locale", () => {
expect(getDictionary("de").discord.gameProfile).toBe("Spielprofil");
});
it("falls back to Turkish for unknown locales", () => {
expect(getDictionary(matchLocale("fr"))).toEqual(getDictionary("tr"));
});
it("keeps all locales structurally in sync", () => {
const shape = (locale: Locale): string => JSON.stringify(getDictionary(locale));
expect(Object.keys(JSON.parse(shape("en"))).sort()).toEqual(
Object.keys(JSON.parse(shape("tr"))).sort(),
);
expect(Object.keys(JSON.parse(shape("de"))).sort()).toEqual(
Object.keys(JSON.parse(shape("tr"))).sort(),
);
});
it("defaults to Turkish", () => {
expect(DEFAULT_LOCALE).toBe("tr");
});
});
+1 -1
View File
@@ -8,5 +8,5 @@ const dictionaries = { tr, en, de } as const;
export type Dictionary = typeof tr;
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale] ?? dictionaries.tr;
return dictionaries[locale];
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
import { cookies } from "next/headers";
import { getLocale } from "@/lib/i18n/get-locale";
describe("getLocale", () => {
it("returns the cookie locale when valid", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "de" }) } as never);
await expect(getLocale()).resolves.toBe("de");
});
it("falls back to default without a cookie", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => undefined } as never);
await expect(getLocale()).resolves.toBe("tr");
});
it("falls back on unknown values", async () => {
vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "fr" }) } as never);
await expect(getLocale()).resolves.toBe("tr");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { isLocale, matchLocale } from "@/lib/i18n/locales";
describe("isLocale", () => {
it("accepts supported locales", () => {
expect(isLocale("tr")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("de")).toBe(true);
});
it("rejects anything else", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("matchLocale", () => {
it("defaults without a header", () => {
expect(matchLocale(null)).toBe("tr");
expect(matchLocale("")).toBe("tr");
});
it("matches primary ranges", () => {
expect(matchLocale("en-US,en;q=0.9")).toBe("en");
expect(matchLocale("de-DE,de;q=0.9,en;q=0.8")).toBe("de");
});
it("respects q-values", () => {
expect(matchLocale("en;q=0.5,tr;q=0.9")).toBe("tr");
});
it("falls back on unknown or wildcard ranges", () => {
expect(matchLocale("fr-FR,fr;q=0.9")).toBe("tr");
expect(matchLocale("*")).toBe("tr");
});
});
-16
View File
@@ -1,16 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { LOCALE_COOKIE, isLocale, type Locale } from "./locales";
export async function setLocaleCookie(locale: Locale): Promise<void> {
if (!isLocale(locale)) {
return;
}
const store = await cookies();
store.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { warnDev } from "@/lib/log";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("warnDev", () => {
it("warns outside production", () => {
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "test" } });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
warnDev("hello");
expect(warn).toHaveBeenCalledWith("hello");
});
it("stays silent in production", () => {
vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "production" } });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
warnDev("hello");
expect(warn).not.toHaveBeenCalled();
});
});
+6
View File
@@ -0,0 +1,6 @@
export function warnDev(message: string): void {
if (process.env.NODE_ENV === "production") {
return;
}
console.warn(message);
}
+4
View File
@@ -7,3 +7,7 @@ export const DISCORD_SERVER_URL = "https://discord.gg/fristy";
export const SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf";
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0";
export const DEV_ORIGINS: ReadonlySet<string> = new Set([
"http://localhost:3000",
"http://127.0.0.1:3000",
]);
+8 -1
View File
@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
@@ -17,15 +19,20 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.5.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^9.39.5",
"eslint-config-next": "16.3.4",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^5.0.0"
},
"packageManager": "pnpm@11.2.2"
}
+1034
View File
File diff suppressed because it is too large. Load diff
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { _resetRateLimit, proxy } from "@/proxy";
afterEach(() => {
_resetRateLimit();
});
function apiRequest(path: string, init?: { method?: string; origin?: string | null }): NextRequest {
const headers = new Headers();
if (init?.origin !== null) {
headers.set("origin", init?.origin ?? "https://fristy.net");
}
return new NextRequest(`https://fristy.net${path}`, {
method: init?.method ?? "GET",
headers,
});
}
describe("proxy /api/*", () => {
it("answers OPTIONS preflight with CORS headers", () => {
const response = proxy(apiRequest("/api/game/1", { method: "OPTIONS" }));
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, OPTIONS");
});
it("rejects foreign origins", () => {
const response = proxy(apiRequest("/api/game/1", { origin: "https://evil.test" }));
expect(response.status).toBe(403);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
it("passes same-origin API requests with CORS headers", () => {
const response = proxy(apiRequest("/api/game/1"));
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
});
});
describe("proxy rate limit", () => {
function ipRequest(ip: string): NextRequest {
const request = apiRequest("/api/game/1");
request.headers.set("cf-connecting-ip", ip);
return request;
}
it("allows requests under the per-IP limit", () => {
for (let i = 0; i < 30; i += 1) {
expect(proxy(ipRequest("10.0.0.1")).status).not.toBe(429);
}
});
it("returns 429 with Retry-After past the limit", async () => {
for (let i = 0; i < 30; i += 1) {
proxy(ipRequest("10.0.0.2"));
}
const limited = proxy(ipRequest("10.0.0.2"));
expect(limited.status).toBe(429);
expect(limited.headers.get("Retry-After")).toBe("60");
expect(limited.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net");
await expect(limited.json()).resolves.toEqual({ error: "Too many requests" });
});
it("isolates buckets per client IP", () => {
for (let i = 0; i < 30; i += 1) {
proxy(ipRequest("10.0.0.3"));
}
expect(proxy(ipRequest("10.0.0.3")).status).toBe(429);
expect(proxy(ipRequest("10.0.0.4")).status).not.toBe(429);
});
it("prefers cf-connecting-ip over x-forwarded-for", () => {
for (let i = 0; i < 30; i += 1) {
const request = apiRequest("/api/game/1");
request.headers.set("cf-connecting-ip", "10.0.0.5");
request.headers.set("x-forwarded-for", "10.0.0.6");
proxy(request);
}
expect(proxy(ipRequest("10.0.0.5")).status).toBe(429);
expect(proxy(ipRequest("10.0.0.6")).status).not.toBe(429);
});
});
describe("proxy locale", () => {
it("sets the locale cookie from accept-language", () => {
const request = apiRequest("/");
request.headers.set("accept-language", "de-DE,de;q=0.9");
const response = proxy(request);
expect(response.cookies.get("locale")?.value).toBe("de");
});
it("passes through when the cookie already exists", () => {
const request = apiRequest("/");
request.cookies.set("locale", "en");
const response = proxy(request);
expect(response.headers.get("set-cookie")).toBeNull();
});
});
+79 -2
View File
@@ -1,8 +1,85 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { LOCALE_COOKIE, matchLocale } from "./lib/i18n/locales";
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-route";
import { LOCALE_COOKIE, matchLocale } from "@/lib/i18n/locales";
export function proxy(request: NextRequest) {
function withCorsHeaders(response: NextResponse, origin: string | null): NextResponse {
const headers = corsHeaders(origin);
for (const [key, value] of Object.entries(headers)) {
response.headers.set(key, value);
}
return response;
}
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 30;
const rateLimitHits = new Map<string, number[]>();
export function _resetRateLimit(): void {
rateLimitHits.clear();
}
export function clientIp(request: NextRequest): string {
const cloudflareIp = request.headers.get("cf-connecting-ip")?.trim();
if (cloudflareIp) {
return cloudflareIp;
}
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
if (forwardedFor) {
return forwardedFor;
}
return "direct";
}
function isRateLimited(ip: string, now: number = Date.now()): boolean {
const cutoff = now - RATE_LIMIT_WINDOW_MS;
const hits = (rateLimitHits.get(ip) ?? []).filter((timestamp) => timestamp > cutoff);
if (hits.length >= RATE_LIMIT_MAX_REQUESTS) {
rateLimitHits.set(ip, hits);
return true;
}
hits.push(now);
rateLimitHits.set(ip, hits);
return false;
}
function rateLimited(origin: string | null): NextResponse {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"Cache-Control": "no-store",
"Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1000)),
...corsHeaders(origin),
},
},
);
}
function handleApi(request: NextRequest): NextResponse {
const origin = request.headers.get("origin");
if (isRateLimited(clientIp(request))) {
return rateLimited(origin);
}
if (!isAllowedOrigin(request)) {
return forbidden(origin);
}
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });
withCorsHeaders(response, origin);
response.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
response.headers.set("Access-Control-Max-Age", "86400");
return response;
}
return withCorsHeaders(NextResponse.next(), origin);
}
export function proxy(request: NextRequest): NextResponse {
if (request.nextUrl.pathname.startsWith("/api/")) {
return handleApi(request);
}
if (request.cookies.has(LOCALE_COOKIE)) {
return NextResponse.next();
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+66
View File
@@ -0,0 +1,66 @@
import type { GiteaRepo } from "@/lib/gitea";
import type { GameProfile } from "@/lib/game/game-profile-types";
import type { LanyardData } from "@/lib/game/lanyard";
export function profileFixture(overrides: Partial<GameProfile> = {}): GameProfile {
return {
applicationId: "1",
gameId: "1",
gameUrl: "https://discord.com/games/1",
name: "Test Game",
genres: ["RPG"],
description: "A test game",
developers: ["Dev"],
publishers: ["Pub"],
releaseDate: "2024-01-15",
platforms: ["Windows"],
links: [],
coverUrl: null,
artworkUrl: null,
screenshots: [],
steamId: "100",
steamUrl: "https://store.steampowered.com/app/100/",
igdbUrl: null,
steamReviews: null,
itad: null,
sources: { igdb: false, steam: true, itad: false },
...overrides,
};
}
export function lanyardFixture(overrides: Partial<LanyardData> = {}): LanyardData {
return {
discord_user: {
id: "1",
username: "testuser",
global_name: "Test User",
display_name: "Test User",
discriminator: "0",
avatar: null,
bot: false,
},
activities: [],
discord_status: "online",
active_on_discord_desktop: true,
active_on_discord_mobile: false,
active_on_discord_web: false,
listening_to_spotify: false,
spotify: null,
...overrides,
};
}
export function repoFixture(): GiteaRepo {
return {
id: 1,
name: "demo",
fullName: "fristy/demo",
description: "A demo repo",
language: "TypeScript",
htmlUrl: "https://git.fristy.net/fristy/demo",
website: null,
stars: 3,
forks: 1,
updatedAt: "2026-01-01T00:00:00Z",
};
}
+12
View File
@@ -0,0 +1,12 @@
import tsconfigPaths from "vite-tsconfig-paths";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
include: ["**/*.test.ts", "**/*.test.tsx"],
exclude: ["node_modules", ".next", "out", "coverage"],
},
});