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
@@ -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,
|
||||
STEAM_URL,
|
||||
} from "@/lib/site";
|
||||
import { CopyEmail } from "./copy-email";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
import { CopyEmail } from "@/components/copy-email";
|
||||
|
||||
import { SectionHeading } from "@/components/section-heading";
|
||||
export function ContactSection({ dict }: { dict: Dictionary["contact"] }) {
|
||||
const entries = [
|
||||
{ label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" },
|
||||
|
||||
@@ -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 { GameProfileModal } from "@/components/game-profile-modal";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import { useLanyard } from "@/hooks/use-lanyard";
|
||||
import {
|
||||
activityImageUrl,
|
||||
DISCORD_ID,
|
||||
discordAvatarUrl,
|
||||
discordGameUrl,
|
||||
formatSpotifyList,
|
||||
LANYARD_WS,
|
||||
lanyardUrl,
|
||||
spotifyTrackUrl,
|
||||
type DiscordAppInfo,
|
||||
type DiscordStatus,
|
||||
type LanyardActivity,
|
||||
type LanyardData,
|
||||
} from "@/lib/lanyard";
|
||||
} from "@/lib/game/lanyard";
|
||||
import { discordGameUrl, type DiscordAppInfo } from "@/lib/game/discord-app";
|
||||
import { fetchJson } from "@/lib/client-fetch";
|
||||
import { formatElapsed } from "@/lib/game/game-format";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
|
||||
const STATUS_DOT: Record<DiscordStatus, string> = {
|
||||
online: "bg-emerald-400",
|
||||
@@ -26,15 +26,13 @@ const STATUS_DOT: Record<DiscordStatus, string> = {
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
const WS_OPEN_TIMEOUT_MS = 10_000;
|
||||
const RECONNECT_BASE_MS = 2_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const SAFETY_POLL_MS = 60_000;
|
||||
const MS_PER_SECOND = 1000;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
|
||||
function formatTrackTime(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / MS_PER_SECOND));
|
||||
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -47,11 +45,12 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) {
|
||||
}, []);
|
||||
|
||||
const total = Math.max(1, end - start);
|
||||
const elapsed = Math.min(Math.max(0, now - start), total);
|
||||
const ratio = Math.min(1, Math.max(0, (now - start) / total));
|
||||
const percent = Math.round(ratio * 100);
|
||||
return (
|
||||
<div className="mt-1.5 flex max-w-60 items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
||||
<span>{formatTrackTime(now - start)}</span>
|
||||
<span>{formatTrackTime(elapsed)}</span>
|
||||
<div
|
||||
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
|
||||
role="progressbar"
|
||||
@@ -66,43 +65,33 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(start: number, lang: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (lang === "tr") {
|
||||
if (hours > 0) {
|
||||
return `${hours}s ${minutes}dk`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}dk`;
|
||||
}
|
||||
return "<1dk";
|
||||
}
|
||||
if (lang === "de") {
|
||||
if (hours > 0) {
|
||||
return `${hours} Std. ${minutes} Min.`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes} Min.`;
|
||||
}
|
||||
return "<1 Min.";
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return "<1m";
|
||||
}
|
||||
const DISCORD_APP_CACHE_LIMIT = 50;
|
||||
|
||||
const discordAppCache = new Map<string, DiscordAppInfo | null>();
|
||||
|
||||
export function _resetDiscordAppCache(): void {
|
||||
discordAppCache.clear();
|
||||
}
|
||||
|
||||
function cacheDiscordApp(id: string, info: DiscordAppInfo): void {
|
||||
if (!discordAppCache.has(id) && discordAppCache.size >= DISCORD_APP_CACHE_LIMIT) {
|
||||
const oldest = discordAppCache.keys().next();
|
||||
if (!oldest.done) {
|
||||
discordAppCache.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
discordAppCache.set(id, info);
|
||||
}
|
||||
|
||||
function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null {
|
||||
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
|
||||
applicationId ? (discordAppCache.get(applicationId) ?? null) : null,
|
||||
);
|
||||
const [prevId, setPrevId] = useState<string | null | undefined>(applicationId);
|
||||
if (prevId !== applicationId) {
|
||||
setPrevId(applicationId);
|
||||
setInfo(applicationId ? (discordAppCache.get(applicationId) ?? null) : null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || discordAppCache.has(applicationId)) {
|
||||
@@ -111,16 +100,12 @@ function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo
|
||||
const id = applicationId;
|
||||
let cancelled = false;
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/discord-app/${id}`);
|
||||
const json = (await response.json()) as DiscordAppInfo | { error: string };
|
||||
const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null;
|
||||
discordAppCache.set(id, resolved);
|
||||
if (!cancelled) {
|
||||
setInfo(resolved);
|
||||
}
|
||||
} catch {
|
||||
discordAppCache.set(id, null);
|
||||
const resolved = await fetchJson<DiscordAppInfo>(`/api/discord-app/${id}`);
|
||||
if (resolved) {
|
||||
cacheDiscordApp(id, resolved);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setInfo(resolved);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
@@ -139,13 +124,13 @@ function ActivityRow({
|
||||
}: {
|
||||
activity: LanyardActivity;
|
||||
dict: Dictionary["discord"];
|
||||
locale: string;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const appInfo = useDiscordApp(activity.application_id);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [heroSeed, setHeroSeed] = useState(0);
|
||||
const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null;
|
||||
const small = activityImageUrl(activity, true);
|
||||
const large = activityImageUrl(activity, "large") ?? appInfo?.iconUrl ?? null;
|
||||
const small = activityImageUrl(activity, "small");
|
||||
const gameUrl = activity.application_id
|
||||
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
|
||||
: null;
|
||||
@@ -229,162 +214,9 @@ export function DiscordCard({
|
||||
}: {
|
||||
initial: LanyardData | null;
|
||||
dict: Dictionary["discord"];
|
||||
locale: string;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const [data, setData] = useState<LanyardData | null>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let heartbeatTimer: number | undefined;
|
||||
let reconnectTimer: number | undefined;
|
||||
let reconnectDelay = RECONNECT_BASE_MS;
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(lanyardUrl(), { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
success: boolean;
|
||||
data: LanyardData;
|
||||
};
|
||||
if (json.success && !disposed) {
|
||||
setData(json.data);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function dropSocket(current: WebSocket): void {
|
||||
if (socket === current) {
|
||||
socket = null;
|
||||
}
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
let current: WebSocket;
|
||||
try {
|
||||
current = new WebSocket(LANYARD_WS);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
socket = current;
|
||||
let settled = false;
|
||||
|
||||
const openTimeout = window.setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
dropSocket(current);
|
||||
if (!disposed) {
|
||||
void pollOnce();
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}, WS_OPEN_TIMEOUT_MS);
|
||||
|
||||
const handleDrop = (): void => {
|
||||
if (settled && socket !== current) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
window.clearTimeout(openTimeout);
|
||||
window.clearInterval(heartbeatTimer);
|
||||
dropSocket(current);
|
||||
if (!disposed) {
|
||||
void pollOnce();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
current.onmessage = (event: MessageEvent) => {
|
||||
let message: { op: number; t?: string; d?: unknown };
|
||||
try {
|
||||
message = JSON.parse(event.data as string) as {
|
||||
op: number;
|
||||
t?: string;
|
||||
d?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.op === 1) {
|
||||
settled = true;
|
||||
window.clearTimeout(openTimeout);
|
||||
reconnectDelay = RECONNECT_BASE_MS;
|
||||
const interval =
|
||||
(message.d as { heartbeat_interval?: number } | undefined)
|
||||
?.heartbeat_interval ?? 30_000;
|
||||
window.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
try {
|
||||
current.send(JSON.stringify({ op: 3 }));
|
||||
} catch {
|
||||
handleDrop();
|
||||
}
|
||||
}, interval);
|
||||
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
|
||||
} else if (
|
||||
message.op === 0 &&
|
||||
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
|
||||
) {
|
||||
if (!disposed) {
|
||||
setData(message.d as LanyardData);
|
||||
}
|
||||
}
|
||||
};
|
||||
current.onerror = handleDrop;
|
||||
current.onclose = handleDrop;
|
||||
}
|
||||
|
||||
connect();
|
||||
const safetyTimer = window.setInterval(() => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
void pollOnce();
|
||||
}
|
||||
}, SAFETY_POLL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearInterval(safetyTimer);
|
||||
window.clearInterval(heartbeatTimer);
|
||||
if (socket) {
|
||||
const current = socket;
|
||||
socket = null;
|
||||
current.onclose = null;
|
||||
current.onerror = null;
|
||||
current.onmessage = null;
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const data = useLanyard(initial);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
|
||||
import { steamdbRating } from "@/lib/game-profile";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
|
||||
type ModalDict = Dictionary["discord"]["gameModal"];
|
||||
|
||||
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
|
||||
"Overwhelmingly Positive": "overwhelminglyPositive",
|
||||
"Very Positive": "veryPositive",
|
||||
Positive: "positive",
|
||||
"Mostly Positive": "mostlyPositive",
|
||||
Mixed: "mixed",
|
||||
"Mostly Negative": "mostlyNegative",
|
||||
Negative: "negative",
|
||||
"Very Negative": "veryNegative",
|
||||
"Overwhelmingly Negative": "overwhelminglyNegative",
|
||||
};
|
||||
|
||||
const LOCALE_TAGS: Record<string, string> = {
|
||||
tr: "tr-TR",
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
};
|
||||
|
||||
const COUNTRIES: Record<string, string> = {
|
||||
tr: "TR",
|
||||
en: "US",
|
||||
de: "DE",
|
||||
};
|
||||
|
||||
function localizeScore(label: string, dict: ModalDict): string {
|
||||
const key = STEAM_SCORE_KEYS[label];
|
||||
return key ? dict.reviewScores[key] : label;
|
||||
}
|
||||
|
||||
function formatRatingPercent(value: number, tag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(tag, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `%${(value * 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(point: GamePricePoint, tag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(tag, {
|
||||
style: "currency",
|
||||
currency: point.currency,
|
||||
}).format(point.price);
|
||||
} catch {
|
||||
return `${point.price} ${point.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseDate(value: string, tag: string): string {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toLocaleDateString(tag, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
|
||||
if (value == null) {
|
||||
return "—";
|
||||
}
|
||||
return `${value.toLocaleString(tag)} ${unit}`;
|
||||
}
|
||||
|
||||
function discountPercent(point: GamePricePoint): number | null {
|
||||
if (point.regular > point.price && point.regular > 0) {
|
||||
return Math.round((1 - point.price / point.regular) * 100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { useEffect } from "react";
|
||||
import { ModalPrices, ModalReviews, ModalScreenshots } from "@/components/game-profile-sections";
|
||||
import { formatReleaseDate, selectGameView, type ModalDict } from "@/lib/game/game-format";
|
||||
import { steamdbAppUrl } from "@/lib/game/discord-app";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
import { useGameProfile } from "@/hooks/use-game-profile";
|
||||
|
||||
export function GameProfileModal({
|
||||
applicationId,
|
||||
@@ -99,48 +20,26 @@ export function GameProfileModal({
|
||||
}: {
|
||||
applicationId: string;
|
||||
heroSeed: number;
|
||||
lang: string;
|
||||
lang: Locale;
|
||||
iconUrl: string | null;
|
||||
fallbackName: string;
|
||||
fallbackGameUrl: string;
|
||||
dict: ModalDict;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<GameProfile | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [bannerPick, setBannerPick] = useState<string | null>(null);
|
||||
const tag = LOCALE_TAGS[lang] ?? "en-US";
|
||||
const country = COUNTRIES[lang] ?? "US";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`game api: ${response.status}`);
|
||||
}
|
||||
const json = (await response.json()) as GameProfile;
|
||||
if (!cancelled) {
|
||||
setProfile(json);
|
||||
setFailed(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applicationId, lang, country, attempt]);
|
||||
const partialFail =
|
||||
!!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad;
|
||||
const {
|
||||
profile,
|
||||
failed,
|
||||
localeTag,
|
||||
hero,
|
||||
hasDetails,
|
||||
partialFail,
|
||||
isDeterministicMiss,
|
||||
canRetry,
|
||||
retry,
|
||||
reset,
|
||||
setBannerPick,
|
||||
} = useGameProfile(applicationId, lang, heroSeed);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent): void {
|
||||
@@ -157,72 +56,11 @@ export function GameProfileModal({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
|
||||
const name = profile?.name ?? fallbackName;
|
||||
const best = profile?.itad?.prices ?? null;
|
||||
const low = profile?.itad?.historyLow ?? null;
|
||||
const isFree = !!best && best.price === 0;
|
||||
const hero = useMemo(() => {
|
||||
if (bannerPick) {
|
||||
return bannerPick;
|
||||
}
|
||||
const shots = profile?.screenshots ?? [];
|
||||
if (shots.length > 0) {
|
||||
return shots[heroSeed % shots.length] ?? null;
|
||||
}
|
||||
return profile?.artworkUrl ?? null;
|
||||
}, [profile, heroSeed, bannerPick]);
|
||||
|
||||
const dbRating =
|
||||
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
|
||||
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
|
||||
: null;
|
||||
const itadReviews = profile?.itad?.reviews ?? [];
|
||||
const hasDetails = !!(
|
||||
profile &&
|
||||
(profile.developers.length > 0 ||
|
||||
profile.publishers.length > 0 ||
|
||||
profile.releaseDate ||
|
||||
profile.platforms.length > 0 ||
|
||||
profile.links.length > 0)
|
||||
const { gameUrl, name, bestPrice, historyLow, isFree } = selectGameView(
|
||||
profile,
|
||||
fallbackName,
|
||||
fallbackGameUrl,
|
||||
);
|
||||
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
|
||||
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
|
||||
const hasReviews = !!(
|
||||
profile?.steamReviews ||
|
||||
(dbRating != null && profile?.steamId) ||
|
||||
metascore ||
|
||||
opencritic
|
||||
);
|
||||
|
||||
const stripRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) {
|
||||
return;
|
||||
}
|
||||
function onWheel(event: WheelEvent): void {
|
||||
const el = stripRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
||||
return;
|
||||
}
|
||||
if (el.scrollWidth <= el.clientWidth + 1) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
el.scrollLeft += event.deltaY;
|
||||
}
|
||||
strip.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => strip.removeEventListener("wheel", onWheel);
|
||||
}, [profile]);
|
||||
|
||||
function scrollStrip(by: number): void {
|
||||
stripRef.current?.scrollBy({ left: by, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -301,26 +139,26 @@ export function GameProfileModal({
|
||||
<p>{dict.loadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFailed(false);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white"
|
||||
onClick={retry}
|
||||
disabled={!canRetry}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{dict.retry}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{partialFail ? (
|
||||
{isDeterministicMiss ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
|
||||
<p className="flex-1">{dict.noGameData}</p>
|
||||
</div>
|
||||
) : partialFail ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
|
||||
<p className="flex-1">{dict.partialFail}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProfile(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white"
|
||||
onClick={reset}
|
||||
disabled={!canRetry}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{dict.retry}
|
||||
</button>
|
||||
@@ -346,7 +184,7 @@ export function GameProfileModal({
|
||||
) : null}
|
||||
{profile?.steamId ? (
|
||||
<a
|
||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||||
href={steamdbAppUrl(profile.steamId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||||
@@ -384,7 +222,7 @@ export function GameProfileModal({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasDetails ? (
|
||||
{profile && hasDetails ? (
|
||||
<dl className="mt-5 grid gap-x-6 gap-y-2 rounded-2xl border border-white/5 bg-white/[0.03] p-4 text-sm md:grid-cols-2">
|
||||
{profile.developers.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
@@ -412,7 +250,7 @@ export function GameProfileModal({
|
||||
{dict.releaseDate}
|
||||
</dt>
|
||||
<dd className="text-zinc-200">
|
||||
{formatReleaseDate(profile.releaseDate, tag)}
|
||||
{formatReleaseDate(profile.releaseDate, localeTag)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -447,193 +285,25 @@ export function GameProfileModal({
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{profile && hasReviews ? (
|
||||
<div className="mt-4">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.reviews}
|
||||
</p>
|
||||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||||
{profile.steamReviews ? (
|
||||
<a
|
||||
href={profile.steamReviews.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.totalReviews}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{localizeScore(profile.steamReviews.label, dict)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(
|
||||
profile.steamReviews.total,
|
||||
tag,
|
||||
dict.reviewsUnit,
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{dbRating != null && profile.steamId ? (
|
||||
<a
|
||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.steamdbRating}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{formatRatingPercent(dbRating, tag)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
steamdb.info ↗
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{metascore ? (
|
||||
<a
|
||||
href={metascore.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
Metacritic
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{metascore.score ?? "—"}
|
||||
<span className="font-normal text-zinc-500">/100</span>
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(metascore.count, tag, dict.reviewsUnit)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{opencritic ? (
|
||||
<a
|
||||
href={opencritic.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
OpenCritic
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{opencritic.score ?? "—"}
|
||||
<span className="font-normal text-zinc-500">/100</span>
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{profile ? <ModalReviews profile={profile} dict={dict} localeTag={localeTag} /> : null}
|
||||
|
||||
{profile?.itad && (best || low) ? (
|
||||
<div className="mt-4">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.prices}
|
||||
</p>
|
||||
{isFree ? (
|
||||
<p className="mt-1.5 inline-block rounded-full border border-mint/20 bg-mint/5 px-4 py-1.5 text-xs font-semibold text-mint">
|
||||
{dict.free}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||||
{best ? (
|
||||
<a
|
||||
href={best.url || undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
|
||||
>
|
||||
<p className="font-mono text-xs text-mint">
|
||||
{dict.bestPrice} · {best.shop}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{formatPrice(best, tag)}{" "}
|
||||
{discountPercent(best) ? (
|
||||
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
|
||||
-%{discountPercent(best)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{low ? (
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.historyLow} · {low.shop}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-zinc-200">
|
||||
{formatPrice(low, tag)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{profile?.itad && (bestPrice || historyLow) ? (
|
||||
<ModalPrices
|
||||
dict={dict}
|
||||
localeTag={localeTag}
|
||||
bestPrice={bestPrice}
|
||||
historyLow={historyLow}
|
||||
isFree={isFree}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{profile && profile.screenshots.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.screenshots}
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollStrip(-360)}
|
||||
aria-label={dict.scrollBack}
|
||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollStrip(360)}
|
||||
aria-label={dict.scrollForward}
|
||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={stripRef}
|
||||
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
|
||||
>
|
||||
{profile.screenshots.map((shot) => (
|
||||
<button
|
||||
key={shot}
|
||||
type="button"
|
||||
onClick={() => setBannerPick(shot)}
|
||||
aria-label={dict.screenshots}
|
||||
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
|
||||
hero === shot
|
||||
? "ring-2 ring-blurple"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={shot}
|
||||
alt=""
|
||||
width={320}
|
||||
height={180}
|
||||
loading="lazy"
|
||||
className="h-auto w-56"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ModalScreenshots
|
||||
profile={profile}
|
||||
dict={dict}
|
||||
hero={hero}
|
||||
onPick={setBannerPick}
|
||||
/>
|
||||
) : null}
|
||||
</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";
|
||||
|
||||
import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, isLocale, type Locale } from "@/lib/i18n/locales";
|
||||
import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
function writeLocaleCookie(locale: Locale): void {
|
||||
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
@@ -8,7 +8,7 @@ function writeLocaleCookie(locale: Locale): void {
|
||||
|
||||
export function LanguageSwitcher({ current }: { current: Locale }) {
|
||||
function switchTo(locale: Locale) {
|
||||
if (locale === current || !isLocale(locale)) {
|
||||
if (locale === current) {
|
||||
return;
|
||||
}
|
||||
writeLocaleCookie(locale);
|
||||
|
||||
@@ -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 { Locale } from "@/lib/i18n/locales";
|
||||
import type { GiteaRepo } from "@/lib/gitea";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
import { SectionHeading } from "@/components/section-heading";
|
||||
|
||||
const LANGUAGE_COLORS: Record<string, string> = {
|
||||
const LANGUAGE_COLORS: Partial<Record<string, string>> = {
|
||||
"C#": "#178600",
|
||||
TypeScript: "#3178c6",
|
||||
JavaScript: "#f1e05a",
|
||||
|
||||
@@ -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 { Locale } from "@/lib/i18n/locales";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
|
||||
export function SiteHeader({ dict, locale }: { dict: Dictionary["nav"]; locale: Locale }) {
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user