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.
212 lines
6.4 KiB
TypeScript
212 lines
6.4 KiB
TypeScript
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],
|
|
};
|
|
}
|