desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
This commit is contained in:
78 files changed
+4908
-1430
No files matched your search
@@ -45,3 +45,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# local code intelligence index
|
# local code intelligence index
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
|
||||||
|
# desloppify local state (scan state, review packets, score snapshots)
|
||||||
|
.desloppify/
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# fristy.net
|
# fristy.net
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
My personal corner of the internet — projects, live Discord status, and ways to reach me.
|
My personal corner of the internet — projects, live Discord status, and ways to reach me.
|
||||||
|
|
||||||
**Live:** https://fristy.net
|
**Live:** https://fristy.net
|
||||||
@@ -29,6 +31,7 @@ pnpm dev # http://localhost:3000
|
|||||||
| `pnpm dev` | Start the dev server |
|
| `pnpm dev` | Start the dev server |
|
||||||
| `pnpm build` | Production build |
|
| `pnpm build` | Production build |
|
||||||
| `pnpm start` | Serve the production build |
|
| `pnpm start` | Serve the production build |
|
||||||
|
| `pnpm test` | Run the Vitest suite |
|
||||||
| `pnpm lint` | Run ESLint |
|
| `pnpm lint` | Run ESLint |
|
||||||
| `pnpm format` | Format everything with Prettier |
|
| `pnpm format` | Format everything with Prettier |
|
||||||
| `pnpm format:check` | Fail if anything is unformatted |
|
| `pnpm format:check` | Fail if anything is unformatted |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,32 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { getDiscordAppInfo } from "@/lib/game/discord-app";
|
||||||
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors";
|
import {
|
||||||
import { getDiscordAppInfo } from "@/lib/lanyard";
|
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) {
|
export async function GET(
|
||||||
if (!isAllowedOrigin(request)) {
|
request: Request,
|
||||||
return forbidden();
|
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, {
|
let info;
|
||||||
status: 204,
|
try {
|
||||||
headers: {
|
info = await getDiscordAppInfo(id);
|
||||||
...corsHeaders(),
|
} catch (error) {
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
warnDev(`discord-app upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`);
|
||||||
"Access-Control-Max-Age": "86400",
|
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) {
|
if (!info) {
|
||||||
return NextResponse.json({ error: "Application not found" }, { status: 404 });
|
return jsonErrorWithCors({ error: "Application not found" }, 404, 60, origin);
|
||||||
}
|
}
|
||||||
return NextResponse.json(info, {
|
return jsonWithCors(info, 86400, origin);
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400",
|
|
||||||
...corsHeaders(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+32
-39
@@ -1,44 +1,37 @@
|
|||||||
import { NextResponse } from "next/server";
|
import {
|
||||||
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors";
|
invalidIdResponse,
|
||||||
import { getGameProfile } from "@/lib/game-profile";
|
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) {
|
export async function GET(
|
||||||
if (!isAllowedOrigin(request)) {
|
request: Request,
|
||||||
return forbidden();
|
context: { params: Promise<{ id: string }> },
|
||||||
}
|
): Promise<NextResponse> {
|
||||||
return new NextResponse(null, {
|
const origin = requestOrigin(request);
|
||||||
status: 204,
|
const id = await parseAppId(context);
|
||||||
headers: {
|
if (!id) {
|
||||||
...corsHeaders(),
|
return invalidIdResponse(origin);
|
||||||
"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 });
|
|
||||||
}
|
}
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const country = /^[A-Z]{2}$/.test(searchParams.get("country") ?? "")
|
const countryRaw = searchParams.get("country") ?? "";
|
||||||
? (searchParams.get("country") as string)
|
const country = /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : "TR";
|
||||||
: "TR";
|
const { lang } = resolveGameLocale(searchParams.get("lang") ?? "");
|
||||||
const lang = ["tr", "en", "de"].includes(searchParams.get("lang") ?? "")
|
let profile;
|
||||||
? (searchParams.get("lang") as string)
|
try {
|
||||||
: "en";
|
profile = await getGameProfile(id, lang, country);
|
||||||
const profile = await getGameProfile(id, country, lang);
|
} catch (error) {
|
||||||
if (!profile) {
|
warnDev(`game upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`);
|
||||||
return NextResponse.json({ error: "Game not found" }, { status: 404 });
|
return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin);
|
||||||
}
|
}
|
||||||
return NextResponse.json(profile, {
|
if (!profile) {
|
||||||
headers: {
|
return jsonErrorWithCors({ error: "Game not found" }, 404, 60, origin);
|
||||||
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
}
|
||||||
...corsHeaders(),
|
return jsonWithCors(profile, 3600, origin);
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -7,7 +7,7 @@ import { SiteHeader } from "@/components/site-header";
|
|||||||
import { getRepos } from "@/lib/gitea";
|
import { getRepos } from "@/lib/gitea";
|
||||||
import { getDictionary } from "@/lib/i18n/get-dictionary";
|
import { getDictionary } from "@/lib/i18n/get-dictionary";
|
||||||
import { getLocale } from "@/lib/i18n/get-locale";
|
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() {
|
export default async function Home() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
SPOTIFY_URL,
|
SPOTIFY_URL,
|
||||||
STEAM_URL,
|
STEAM_URL,
|
||||||
} from "@/lib/site";
|
} from "@/lib/site";
|
||||||
import { CopyEmail } from "./copy-email";
|
import { CopyEmail } from "@/components/copy-email";
|
||||||
import { SectionHeading } from "./section-heading";
|
|
||||||
|
|
||||||
|
import { SectionHeading } from "@/components/section-heading";
|
||||||
export function ContactSection({ dict }: { dict: Dictionary["contact"] }) {
|
export function ContactSection({ dict }: { dict: Dictionary["contact"] }) {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" },
|
{ label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" },
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+44
-212
@@ -4,20 +4,20 @@ import Image from "next/image";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { GameProfileModal } from "@/components/game-profile-modal";
|
import { GameProfileModal } from "@/components/game-profile-modal";
|
||||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||||
|
import { useLanyard } from "@/hooks/use-lanyard";
|
||||||
import {
|
import {
|
||||||
activityImageUrl,
|
activityImageUrl,
|
||||||
DISCORD_ID,
|
|
||||||
discordAvatarUrl,
|
discordAvatarUrl,
|
||||||
discordGameUrl,
|
|
||||||
formatSpotifyList,
|
formatSpotifyList,
|
||||||
LANYARD_WS,
|
|
||||||
lanyardUrl,
|
|
||||||
spotifyTrackUrl,
|
spotifyTrackUrl,
|
||||||
type DiscordAppInfo,
|
|
||||||
type DiscordStatus,
|
type DiscordStatus,
|
||||||
type LanyardActivity,
|
type LanyardActivity,
|
||||||
type LanyardData,
|
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> = {
|
const STATUS_DOT: Record<DiscordStatus, string> = {
|
||||||
online: "bg-emerald-400",
|
online: "bg-emerald-400",
|
||||||
@@ -26,15 +26,13 @@ const STATUS_DOT: Record<DiscordStatus, string> = {
|
|||||||
offline: "bg-zinc-500",
|
offline: "bg-zinc-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
const WS_OPEN_TIMEOUT_MS = 10_000;
|
const MS_PER_SECOND = 1000;
|
||||||
const RECONNECT_BASE_MS = 2_000;
|
const SECONDS_PER_MINUTE = 60;
|
||||||
const RECONNECT_MAX_MS = 30_000;
|
|
||||||
const SAFETY_POLL_MS = 60_000;
|
|
||||||
|
|
||||||
function formatTrackTime(ms: number): string {
|
function formatTrackTime(ms: number): string {
|
||||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
const totalSeconds = Math.max(0, Math.floor(ms / MS_PER_SECOND));
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
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 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 ratio = Math.min(1, Math.max(0, (now - start) / total));
|
||||||
const percent = Math.round(ratio * 100);
|
const percent = Math.round(ratio * 100);
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5 flex max-w-60 items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
<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
|
<div
|
||||||
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
|
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
@@ -66,43 +65,33 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(start: number, lang: string): string {
|
const DISCORD_APP_CACHE_LIMIT = 50;
|
||||||
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 discordAppCache = new Map<string, DiscordAppInfo | null>();
|
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 {
|
function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null {
|
||||||
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
|
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
|
||||||
applicationId ? (discordAppCache.get(applicationId) ?? null) : 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(() => {
|
useEffect(() => {
|
||||||
if (!applicationId || discordAppCache.has(applicationId)) {
|
if (!applicationId || discordAppCache.has(applicationId)) {
|
||||||
@@ -111,16 +100,12 @@ function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo
|
|||||||
const id = applicationId;
|
const id = applicationId;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
try {
|
const resolved = await fetchJson<DiscordAppInfo>(`/api/discord-app/${id}`);
|
||||||
const response = await fetch(`/api/discord-app/${id}`);
|
if (resolved) {
|
||||||
const json = (await response.json()) as DiscordAppInfo | { error: string };
|
cacheDiscordApp(id, resolved);
|
||||||
const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null;
|
}
|
||||||
discordAppCache.set(id, resolved);
|
if (!cancelled) {
|
||||||
if (!cancelled) {
|
setInfo(resolved);
|
||||||
setInfo(resolved);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
discordAppCache.set(id, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
@@ -139,13 +124,13 @@ function ActivityRow({
|
|||||||
}: {
|
}: {
|
||||||
activity: LanyardActivity;
|
activity: LanyardActivity;
|
||||||
dict: Dictionary["discord"];
|
dict: Dictionary["discord"];
|
||||||
locale: string;
|
locale: Locale;
|
||||||
}) {
|
}) {
|
||||||
const appInfo = useDiscordApp(activity.application_id);
|
const appInfo = useDiscordApp(activity.application_id);
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [heroSeed, setHeroSeed] = useState(0);
|
const [heroSeed, setHeroSeed] = useState(0);
|
||||||
const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null;
|
const large = activityImageUrl(activity, "large") ?? appInfo?.iconUrl ?? null;
|
||||||
const small = activityImageUrl(activity, true);
|
const small = activityImageUrl(activity, "small");
|
||||||
const gameUrl = activity.application_id
|
const gameUrl = activity.application_id
|
||||||
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
|
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
|
||||||
: null;
|
: null;
|
||||||
@@ -229,162 +214,9 @@ export function DiscordCard({
|
|||||||
}: {
|
}: {
|
||||||
initial: LanyardData | null;
|
initial: LanyardData | null;
|
||||||
dict: Dictionary["discord"];
|
dict: Dictionary["discord"];
|
||||||
locale: string;
|
locale: Locale;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<LanyardData | null>(initial);
|
const data = useLanyard(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,91 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
|
import { ModalPrices, ModalReviews, ModalScreenshots } from "@/components/game-profile-sections";
|
||||||
import { steamdbRating } from "@/lib/game-profile";
|
import { formatReleaseDate, selectGameView, type ModalDict } from "@/lib/game/game-format";
|
||||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
import { steamdbAppUrl } from "@/lib/game/discord-app";
|
||||||
|
import type { Locale } from "@/lib/i18n/locales";
|
||||||
type ModalDict = Dictionary["discord"]["gameModal"];
|
import { useGameProfile } from "@/hooks/use-game-profile";
|
||||||
|
|
||||||
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
|
|
||||||
"Overwhelmingly Positive": "overwhelminglyPositive",
|
|
||||||
"Very Positive": "veryPositive",
|
|
||||||
Positive: "positive",
|
|
||||||
"Mostly Positive": "mostlyPositive",
|
|
||||||
Mixed: "mixed",
|
|
||||||
"Mostly Negative": "mostlyNegative",
|
|
||||||
Negative: "negative",
|
|
||||||
"Very Negative": "veryNegative",
|
|
||||||
"Overwhelmingly Negative": "overwhelminglyNegative",
|
|
||||||
};
|
|
||||||
|
|
||||||
const LOCALE_TAGS: Record<string, string> = {
|
|
||||||
tr: "tr-TR",
|
|
||||||
en: "en-US",
|
|
||||||
de: "de-DE",
|
|
||||||
};
|
|
||||||
|
|
||||||
const COUNTRIES: Record<string, string> = {
|
|
||||||
tr: "TR",
|
|
||||||
en: "US",
|
|
||||||
de: "DE",
|
|
||||||
};
|
|
||||||
|
|
||||||
function localizeScore(label: string, dict: ModalDict): string {
|
|
||||||
const key = STEAM_SCORE_KEYS[label];
|
|
||||||
return key ? dict.reviewScores[key] : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRatingPercent(value: number, tag: string): string {
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(tag, {
|
|
||||||
style: "percent",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value);
|
|
||||||
} catch {
|
|
||||||
return `%${(value * 100).toFixed(2)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPrice(point: GamePricePoint, tag: string): string {
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(tag, {
|
|
||||||
style: "currency",
|
|
||||||
currency: point.currency,
|
|
||||||
}).format(point.price);
|
|
||||||
} catch {
|
|
||||||
return `${point.price} ${point.currency}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReleaseDate(value: string, tag: string): string {
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
||||||
const parsed = new Date(`${value}T00:00:00`);
|
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
|
||||||
return parsed.toLocaleDateString(tag, {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
|
|
||||||
if (value == null) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
return `${value.toLocaleString(tag)} ${unit}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function discountPercent(point: GamePricePoint): number | null {
|
|
||||||
if (point.regular > point.price && point.regular > 0) {
|
|
||||||
return Math.round((1 - point.price / point.regular) * 100);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameProfileModal({
|
export function GameProfileModal({
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -99,48 +20,26 @@ export function GameProfileModal({
|
|||||||
}: {
|
}: {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
heroSeed: number;
|
heroSeed: number;
|
||||||
lang: string;
|
lang: Locale;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
fallbackName: string;
|
fallbackName: string;
|
||||||
fallbackGameUrl: string;
|
fallbackGameUrl: string;
|
||||||
dict: ModalDict;
|
dict: ModalDict;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [profile, setProfile] = useState<GameProfile | null>(null);
|
const {
|
||||||
const [failed, setFailed] = useState(false);
|
profile,
|
||||||
const [attempt, setAttempt] = useState(0);
|
failed,
|
||||||
const [bannerPick, setBannerPick] = useState<string | null>(null);
|
localeTag,
|
||||||
const tag = LOCALE_TAGS[lang] ?? "en-US";
|
hero,
|
||||||
const country = COUNTRIES[lang] ?? "US";
|
hasDetails,
|
||||||
|
partialFail,
|
||||||
useEffect(() => {
|
isDeterministicMiss,
|
||||||
let cancelled = false;
|
canRetry,
|
||||||
async function load(): Promise<void> {
|
retry,
|
||||||
try {
|
reset,
|
||||||
const response = await fetch(
|
setBannerPick,
|
||||||
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
|
} = useGameProfile(applicationId, lang, heroSeed);
|
||||||
);
|
|
||||||
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;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(event: KeyboardEvent): void {
|
function onKey(event: KeyboardEvent): void {
|
||||||
@@ -157,72 +56,11 @@ export function GameProfileModal({
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
|
const { gameUrl, name, bestPrice, historyLow, isFree } = selectGameView(
|
||||||
const name = profile?.name ?? fallbackName;
|
profile,
|
||||||
const best = profile?.itad?.prices ?? null;
|
fallbackName,
|
||||||
const low = profile?.itad?.historyLow ?? null;
|
fallbackGameUrl,
|
||||||
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -301,26 +139,26 @@ export function GameProfileModal({
|
|||||||
<p>{dict.loadError}</p>
|
<p>{dict.loadError}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={retry}
|
||||||
setFailed(false);
|
disabled={!canRetry}
|
||||||
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 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{dict.retry}
|
{dict.retry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<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>
|
<p className="flex-1">{dict.partialFail}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={reset}
|
||||||
setProfile(null);
|
disabled={!canRetry}
|
||||||
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 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{dict.retry}
|
{dict.retry}
|
||||||
</button>
|
</button>
|
||||||
@@ -346,7 +184,7 @@ export function GameProfileModal({
|
|||||||
) : null}
|
) : null}
|
||||||
{profile?.steamId ? (
|
{profile?.steamId ? (
|
||||||
<a
|
<a
|
||||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
href={steamdbAppUrl(profile.steamId)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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}
|
) : null}
|
||||||
</div>
|
</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">
|
<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 ? (
|
{profile.developers.length > 0 ? (
|
||||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||||
@@ -412,7 +250,7 @@ export function GameProfileModal({
|
|||||||
{dict.releaseDate}
|
{dict.releaseDate}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-zinc-200">
|
<dd className="text-zinc-200">
|
||||||
{formatReleaseDate(profile.releaseDate, tag)}
|
{formatReleaseDate(profile.releaseDate, localeTag)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -447,193 +285,25 @@ export function GameProfileModal({
|
|||||||
</dl>
|
</dl>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{profile && hasReviews ? (
|
{profile ? <ModalReviews profile={profile} dict={dict} localeTag={localeTag} /> : null}
|
||||||
<div className="mt-4">
|
|
||||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
|
||||||
{dict.reviews}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
|
||||||
{profile.steamReviews ? (
|
|
||||||
<a
|
|
||||||
href={profile.steamReviews.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
|
||||||
>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{dict.totalReviews}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold">
|
|
||||||
{localizeScore(profile.steamReviews.label, dict)}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{formatCount(
|
|
||||||
profile.steamReviews.total,
|
|
||||||
tag,
|
|
||||||
dict.reviewsUnit,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{dbRating != null && profile.steamId ? (
|
|
||||||
<a
|
|
||||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
|
||||||
>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{dict.steamdbRating}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold">
|
|
||||||
{formatRatingPercent(dbRating, tag)}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
steamdb.info ↗
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{metascore ? (
|
|
||||||
<a
|
|
||||||
href={metascore.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
|
||||||
>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
Metacritic
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold">
|
|
||||||
{metascore.score ?? "—"}
|
|
||||||
<span className="font-normal text-zinc-500">/100</span>
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{formatCount(metascore.count, tag, dict.reviewsUnit)}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{opencritic ? (
|
|
||||||
<a
|
|
||||||
href={opencritic.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
|
||||||
>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
OpenCritic
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold">
|
|
||||||
{opencritic.score ?? "—"}
|
|
||||||
<span className="font-normal text-zinc-500">/100</span>
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{profile?.itad && (best || low) ? (
|
{profile?.itad && (bestPrice || historyLow) ? (
|
||||||
<div className="mt-4">
|
<ModalPrices
|
||||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
dict={dict}
|
||||||
{dict.prices}
|
localeTag={localeTag}
|
||||||
</p>
|
bestPrice={bestPrice}
|
||||||
{isFree ? (
|
historyLow={historyLow}
|
||||||
<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">
|
isFree={isFree}
|
||||||
{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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{profile && profile.screenshots.length > 0 ? (
|
{profile && profile.screenshots.length > 0 ? (
|
||||||
<div className="mt-4">
|
<ModalScreenshots
|
||||||
<div className="flex items-center justify-between">
|
profile={profile}
|
||||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
dict={dict}
|
||||||
{dict.screenshots}
|
hero={hero}
|
||||||
</p>
|
onPick={setBannerPick}
|
||||||
<div className="flex gap-1.5">
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => scrollStrip(-360)}
|
|
||||||
aria-label={dict.scrollBack}
|
|
||||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => scrollStrip(360)}
|
|
||||||
aria-label={dict.scrollForward}
|
|
||||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={stripRef}
|
|
||||||
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
|
|
||||||
>
|
|
||||||
{profile.screenshots.map((shot) => (
|
|
||||||
<button
|
|
||||||
key={shot}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBannerPick(shot)}
|
|
||||||
aria-label={dict.screenshots}
|
|
||||||
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
|
|
||||||
hero === shot
|
|
||||||
? "ring-2 ring-blurple"
|
|
||||||
: "opacity-70 hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={shot}
|
|
||||||
alt=""
|
|
||||||
width={320}
|
|
||||||
height={180}
|
|
||||||
loading="lazy"
|
|
||||||
className="h-auto w-56"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 {
|
function writeLocaleCookie(locale: Locale): void {
|
||||||
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`;
|
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 }) {
|
export function LanguageSwitcher({ current }: { current: Locale }) {
|
||||||
function switchTo(locale: Locale) {
|
function switchTo(locale: Locale) {
|
||||||
if (locale === current || !isLocale(locale)) {
|
if (locale === current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeLocaleCookie(locale);
|
writeLocaleCookie(locale);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||||
import type { Locale } from "@/lib/i18n/locales";
|
import type { Locale } from "@/lib/i18n/locales";
|
||||||
import type { GiteaRepo } from "@/lib/gitea";
|
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",
|
"C#": "#178600",
|
||||||
TypeScript: "#3178c6",
|
TypeScript: "#3178c6",
|
||||||
JavaScript: "#f1e05a",
|
JavaScript: "#f1e05a",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||||
import type { Locale } from "@/lib/i18n/locales";
|
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 }) {
|
export function SiteHeader({ dict, locale }: { dict: Dictionary["nav"]; locale: Locale }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { warnDev } from "@/lib/log";
|
||||||
|
|
||||||
export const DISCORD_ID = "1014549805608992901";
|
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";
|
export const LANYARD_WS = "wss://api.lanyard.rest/socket";
|
||||||
|
|
||||||
@@ -31,8 +33,13 @@ export interface LanyardActivity {
|
|||||||
assets?: LanyardActivityAssets | null;
|
assets?: LanyardActivityAssets | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null {
|
export type ActivityImageSize = "small" | "large";
|
||||||
const key = small ? activity.assets?.small_image : activity.assets?.large_image;
|
|
||||||
|
export function activityImageUrl(
|
||||||
|
activity: LanyardActivity,
|
||||||
|
size: ActivityImageSize = "large",
|
||||||
|
): string | null {
|
||||||
|
const key = size === "small" ? activity.assets?.small_image : activity.assets?.large_image;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -75,75 +82,6 @@ export function spotifyTrackUrl(trackId: string): string {
|
|||||||
return `https://open.spotify.com/track/${trackId}`;
|
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 {
|
export interface LanyardData {
|
||||||
discord_user: {
|
discord_user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -172,19 +110,17 @@ export async function getLanyard(): Promise<LanyardData | null> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
|
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
warnDev(`lanyard failed: ${response.status}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const json = (await response.json()) as LanyardResponse;
|
const json = (await response.json()) as LanyardResponse;
|
||||||
return json.success ? json.data : null;
|
return json.success ? json.data : null;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
warnDev(`lanyard request failed: ${error instanceof Error ? error.message : error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lanyardUrl(): string {
|
|
||||||
return LANYARD_API;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
|
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
|
||||||
if (!avatarHash) {
|
if (!avatarHash) {
|
||||||
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
|
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,3 +1,5 @@
|
|||||||
|
import { warnDev } from "@/lib/log";
|
||||||
|
|
||||||
const GITEA_API = "https://git.fristy.net/api/v1";
|
const GITEA_API = "https://git.fristy.net/api/v1";
|
||||||
const GITEA_USER = "fristy";
|
const GITEA_USER = "fristy";
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
|||||||
{ next: { revalidate: 3600 } },
|
{ next: { revalidate: 3600 } },
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`cannot fetch gitea repos: ${response.status}`);
|
warnDev(`cannot fetch gitea repos: ${response.status}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as GiteaApiRepo[];
|
const data = (await response.json()) as GiteaApiRepo[];
|
||||||
@@ -55,8 +57,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
|||||||
updatedAt: repo.updated_at,
|
updatedAt: repo.updated_at,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
|
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("cannot fetch gitea repos:", error);
|
warnDev("cannot fetch gitea repos");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,11 +49,12 @@
|
|||||||
"loadError": "Spieldaten konnten nicht geladen werden.",
|
"loadError": "Spieldaten konnten nicht geladen werden.",
|
||||||
"retry": "Erneut versuchen",
|
"retry": "Erneut versuchen",
|
||||||
"partialFail": "Einige Daten konnten nicht geladen werden.",
|
"partialFail": "Einige Daten konnten nicht geladen werden.",
|
||||||
|
"noGameData": "Für diese App wurden keine Spieldaten gefunden.",
|
||||||
"screenshots": "Screenshots",
|
"screenshots": "Screenshots",
|
||||||
"reviewsUnit": "Bewertungen",
|
"reviewsUnit": "Bewertungen",
|
||||||
"scrollBack": "Zurück",
|
"scrollBack": "Zurück",
|
||||||
"scrollForward": "Weiter",
|
"scrollForward": "Weiter",
|
||||||
"totalReviews": "Bewertungen insgesamt",
|
"totalReviews": "Steam",
|
||||||
"steamdbRating": "SteamDB-Wertung",
|
"steamdbRating": "SteamDB-Wertung",
|
||||||
"reviewScores": {
|
"reviewScores": {
|
||||||
"overwhelminglyPositive": "Äußerst positiv",
|
"overwhelminglyPositive": "Äußerst positiv",
|
||||||
|
|||||||
@@ -49,11 +49,12 @@
|
|||||||
"loadError": "Couldn't load game info.",
|
"loadError": "Couldn't load game info.",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"partialFail": "Some data failed to load.",
|
"partialFail": "Some data failed to load.",
|
||||||
|
"noGameData": "No game data found for this app.",
|
||||||
"screenshots": "Screenshots",
|
"screenshots": "Screenshots",
|
||||||
"reviewsUnit": "reviews",
|
"reviewsUnit": "reviews",
|
||||||
"scrollBack": "Back",
|
"scrollBack": "Back",
|
||||||
"scrollForward": "Forward",
|
"scrollForward": "Forward",
|
||||||
"totalReviews": "Total Reviews",
|
"totalReviews": "Steam",
|
||||||
"steamdbRating": "SteamDB Rating",
|
"steamdbRating": "SteamDB Rating",
|
||||||
"reviewScores": {
|
"reviewScores": {
|
||||||
"overwhelminglyPositive": "Overwhelmingly Positive",
|
"overwhelminglyPositive": "Overwhelmingly Positive",
|
||||||
|
|||||||
@@ -49,11 +49,12 @@
|
|||||||
"loadError": "Oyun bilgileri yüklenemedi.",
|
"loadError": "Oyun bilgileri yüklenemedi.",
|
||||||
"retry": "Tekrar Dene",
|
"retry": "Tekrar Dene",
|
||||||
"partialFail": "Bazı veriler yüklenemedi.",
|
"partialFail": "Bazı veriler yüklenemedi.",
|
||||||
|
"noGameData": "Bu uygulama için oyun verisi bulunamadı.",
|
||||||
"screenshots": "Görseller",
|
"screenshots": "Görseller",
|
||||||
"reviewsUnit": "değerlendirme",
|
"reviewsUnit": "değerlendirme",
|
||||||
"scrollBack": "Geri",
|
"scrollBack": "Geri",
|
||||||
"scrollForward": "İleri",
|
"scrollForward": "İleri",
|
||||||
"totalReviews": "Toplam Değerlendirme",
|
"totalReviews": "Steam",
|
||||||
"steamdbRating": "SteamDB Puanı",
|
"steamdbRating": "SteamDB Puanı",
|
||||||
"reviewScores": {
|
"reviewScores": {
|
||||||
"overwhelminglyPositive": "Son Derece Olumlu",
|
"overwhelminglyPositive": "Son Derece Olumlu",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@ const dictionaries = { tr, en, de } as const;
|
|||||||
export type Dictionary = typeof tr;
|
export type Dictionary = typeof tr;
|
||||||
|
|
||||||
export function getDictionary(locale: Locale): Dictionary {
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
return dictionaries[locale] ?? dictionaries.tr;
|
return dictionaries[locale];
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function warnDev(message: string): void {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(message);
|
||||||
|
}
|
||||||
@@ -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 SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf";
|
||||||
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
|
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
|
||||||
export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0";
|
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
@@ -7,6 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
@@ -17,15 +19,20 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.5.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.3.4",
|
"eslint-config-next": "16.3.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.3.3",
|
"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"
|
"packageManager": "pnpm@11.2.2"
|
||||||
}
|
}
|
||||||
Generated
+1034
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,85 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } 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)) {
|
if (request.cookies.has(LOCALE_COOKIE)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user