Add multi-hotel Habbo gamedata locale in CMS settings.
Local Build and Deploy / deploy (push) Failing after 33s

Furni name suggestions, import enrichment, and badge texts now follow habbo_gamedata_hotel instead of hardcoded habbo.it.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 22:28:18 +02:00
1 parent 7bc0845932
commit d1baaf798a
15 files changed
+276 -66

No files matched your search

+39 -5
View File
@@ -5,12 +5,32 @@ import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config"; import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { actionOk, adminAction } from "@/lib/foundation/action"; import { actionOk, adminAction } from "@/lib/foundation/action";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS); const managedKeySet = new Set(MANAGED_SETTING_KEYS);
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
}
return value;
}
function bustGamedataCachesIfNeeded(key: string): void {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
clearHabboItCache();
clearBadgeCache();
}
}
const saveManagedSchema = z.object({ const saveManagedSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
}); });
@@ -23,9 +43,9 @@ export const saveManagedSettings = adminAction(
rateLimitMax: 30, rateLimitMax: 30,
}, },
async (ctx) => { async (ctx) => {
const entries = Object.entries(ctx.data.settings).filter(([key]) => const entries = Object.entries(ctx.data.settings)
managedKeySet.has(key), .filter(([key]) => managedKeySet.has(key))
); .map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all( await Promise.all(
entries.map(([key, value]) => entries.map(([key, value]) =>
prisma.websiteSetting.upsert({ prisma.websiteSetting.upsert({
@@ -36,7 +56,12 @@ export const saveManagedSettings = adminAction(
), ),
); );
siteSettings.reload(); siteSettings.reload();
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
clearHabboItCache();
clearBadgeCache();
}
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
revalidatePath("/admin/catalog");
return actionOk({ saved: entries.length }); return actionOk({ saved: entries.length });
}, },
); );
@@ -46,10 +71,14 @@ export async function updateSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const value = String(formData.get("value") ?? "").normalize("NFC"); const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return; if (!key) return;
await prisma.websiteSetting.update({ where: { key }, data: { value } }); await prisma.websiteSetting.update({ where: { key }, data: { value } });
siteSettings.reload(); siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
@@ -59,7 +88,10 @@ export async function createSetting(formData: FormData): Promise<void> {
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const value = String(formData.get("value") ?? "").normalize("NFC"); const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
const comment = String(formData.get("comment") ?? "") const comment = String(formData.get("comment") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -71,6 +103,7 @@ export async function createSetting(formData: FormData): Promise<void> {
create: { key, value, comment: comment || null }, create: { key, value, comment: comment || null },
}); });
siteSettings.reload(); siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
@@ -82,5 +115,6 @@ export async function deleteSetting(formData: FormData): Promise<void> {
if (!key) return; if (!key) return;
await prisma.websiteSetting.delete({ where: { key } }); await prisma.websiteSetting.delete({ where: { key } });
siteSettings.reload(); siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
@@ -49,6 +49,7 @@ interface CatalogDetailTabsProps {
furniRevisionMap: Record<number, { classname: string; revision: number }>; furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[]; allPages: { id: number; caption: string }[];
interactionTypes: string[]; interactionTypes: string[];
gamedataHotel: string;
} }
export function CatalogDetailTabs({ export function CatalogDetailTabs({
@@ -64,6 +65,7 @@ export function CatalogDetailTabs({
furniRevisionMap, furniRevisionMap,
allPages, allPages,
interactionTypes, interactionTypes,
gamedataHotel,
}: CatalogDetailTabsProps) { }: CatalogDetailTabsProps) {
return ( return (
<Tabs defaultValue="settings"> <Tabs defaultValue="settings">
@@ -119,6 +121,7 @@ export function CatalogDetailTabs({
furniDataIdList={furniDataIdList} furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap} furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap} furniRevisionMap={furniRevisionMap}
gamedataHotel={gamedataHotel}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
File diff suppressed because it is too large. Load diff
+2
View File
@@ -47,6 +47,7 @@ export default async function CatalogEditPage({
furniRevisionMap, furniRevisionMap,
interactionTypes, interactionTypes,
allPages, allPages,
gamedataHotel,
} = itemsData; } = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank); const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
@@ -124,6 +125,7 @@ export default async function CatalogEditPage({
furniRevisionMap={furniRevisionMap} furniRevisionMap={furniRevisionMap}
allPages={allPages} allPages={allPages}
interactionTypes={interactionTypes} interactionTypes={interactionTypes}
gamedataHotel={gamedataHotel}
/> />
</div> </div>
); );
+17 -1
View File
@@ -1,10 +1,13 @@
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
export type FieldType = export type FieldType =
| "text" | "text"
| "password" | "password"
| "url" | "url"
| "number" | "number"
| "boolean" | "boolean"
| "textarea"; | "textarea"
| "select";
export type SettingsGroupIcon = export type SettingsGroupIcon =
| "building" | "building"
@@ -22,6 +25,7 @@ export interface ManagedField {
type?: FieldType; type?: FieldType;
placeholder?: string; placeholder?: string;
defaultValue?: string; defaultValue?: string;
options?: Array<{ value: string; label: string }>;
} }
export interface SettingsGroup { export interface SettingsGroup {
@@ -150,6 +154,18 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
icon: "music", icon: "music",
description: "Public URLs and overrides for SWF / Nitro asset packages.", description: "Public URLs and overrides for SWF / Nitro asset packages.",
fields: [ fields: [
{
key: "habbo_gamedata_hotel",
label: "Habbo hotel for furni names",
description:
"Official Habbo locale used for catalog name suggestions, furni import enrichment, and badge text lookup (furnidata / external texts).",
type: "select",
defaultValue: "it",
options: HABBO_GAMEDATA_HOTELS.map((h) => ({
value: h.value,
label: h.label,
})),
},
{ {
key: "soundtrack_base_url", key: "soundtrack_base_url",
label: "Song disks MP3 base URL", label: "Song disks MP3 base URL",
@@ -197,6 +197,20 @@ function FieldControl({
onChange={(e) => onChange(field.key, e.target.value)} onChange={(e) => onChange(field.key, e.target.value)}
rows={3} rows={3}
/> />
) : type === "select" ? (
<select
id={field.key}
value={value}
disabled={!canEdit}
onChange={(e) => onChange(field.key, e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : ( ) : (
<Input <Input
id={field.key} id={field.key}
+7 -1
View File
@@ -1,11 +1,13 @@
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response"; import { apiOk } from "@/lib/api-response";
import { getHabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { batchLookupByClassnames } from "@/lib/services/habbofurni"; import { batchLookupByClassnames } from "@/lib/services/habbofurni";
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
async (request) => { async (request) => {
const hotel = await getHabboGamedataHotel();
const raw = request.nextUrl.searchParams.get("classnames") || ""; const raw = request.nextUrl.searchParams.get("classnames") || "";
const classnames = raw const classnames = raw
.split(",") .split(",")
@@ -13,6 +15,10 @@ export const GET = withAdmin(
.filter(Boolean) .filter(Boolean)
.slice(0, 100); .slice(0, 100);
if (classnames.length === 0) {
return apiOk({ hotel, suggestions: {} });
}
const found = await batchLookupByClassnames(classnames); const found = await batchLookupByClassnames(classnames);
const suggestions: Record<string, { name: string; description: string }> = const suggestions: Record<string, { name: string; description: string }> =
{}; {};
@@ -24,6 +30,6 @@ export const GET = withAdmin(
}; };
} }
return apiOk({ suggestions }); return apiOk({ hotel, suggestions });
}, },
); );
@@ -84,6 +84,7 @@ interface ItemsData {
furniRevisionMap: Record<number, { classname: string; revision: number }>; furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[]; interactionTypes: string[];
allPages: { id: number; caption: string }[]; allPages: { id: number; caption: string }[];
gamedataHotel?: string;
} }
export interface PageUpdateData { export interface PageUpdateData {
@@ -691,6 +692,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
furniDataIdList={itemsData.furniDataIdList} furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap} furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap} furniRevisionMap={itemsData.furniRevisionMap}
gamedataHotel={itemsData.gamedataHotel}
/> />
) : null} ) : null}
</TabsContent> </TabsContent>
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
habboExternalTextsUrl,
habboFurnidataUrl,
habboGamedataHotelShort,
normalizeHabboGamedataHotel,
} from "./habbo-gamedata-hotel";
describe("habbo-gamedata-hotel", () => {
it("normalizes known hotels and falls back to it", () => {
expect(normalizeHabboGamedataHotel("ES")).toBe("es");
expect(normalizeHabboGamedataHotel("com.br")).toBe("com.br");
expect(normalizeHabboGamedataHotel("nope")).toBe("it");
expect(normalizeHabboGamedataHotel(null)).toBe("it");
});
it("builds official gamedata URLs", () => {
expect(habboFurnidataUrl("de")).toBe(
"https://www.habbo.de/gamedata/furnidata_json/1",
);
expect(habboExternalTextsUrl("com.br")).toBe(
"https://www.habbo.com.br/gamedata/external_flash_texts/1",
);
});
it("formats short hotel labels", () => {
expect(habboGamedataHotelShort("it")).toBe("IT");
expect(habboGamedataHotelShort("com.br")).toBe("COM.BR");
});
});
+57
View File
@@ -0,0 +1,57 @@
/** Official Habbo hotel TLDs used for gamedata (furnidata / external texts). */
export const HABBO_GAMEDATA_HOTELS = [
{ value: "it", label: "Italy (habbo.it)" },
{ value: "com", label: "International (habbo.com)" },
{ value: "es", label: "Spain (habbo.es)" },
{ value: "de", label: "Germany (habbo.de)" },
{ value: "fr", label: "France (habbo.fr)" },
{ value: "fi", label: "Finland (habbo.fi)" },
{ value: "nl", label: "Netherlands (habbo.nl)" },
{ value: "com.br", label: "Brazil (habbo.com.br)" },
{ value: "com.tr", label: "Turkey (habbo.com.tr)" },
] as const;
export type HabboGamedataHotel =
(typeof HABBO_GAMEDATA_HOTELS)[number]["value"];
export const HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel";
export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it";
const ALLOWED = new Set<string>(HABBO_GAMEDATA_HOTELS.map((h) => h.value));
export function normalizeHabboGamedataHotel(
raw: string | null | undefined,
): HabboGamedataHotel {
const v = (raw ?? "").trim().toLowerCase();
if (ALLOWED.has(v)) return v as HabboGamedataHotel;
return DEFAULT_HABBO_GAMEDATA_HOTEL;
}
export function habboGamedataHotelLabel(hotel: string): string {
const found = HABBO_GAMEDATA_HOTELS.find((h) => h.value === hotel);
return found?.label ?? `habbo.${hotel}`;
}
/** Short badge for buttons, e.g. IT / COM / COM.BR */
export function habboGamedataHotelShort(hotel: string): string {
return normalizeHabboGamedataHotel(hotel).toUpperCase();
}
export function habboFurnidataUrl(hotel: string): string {
const h = normalizeHabboGamedataHotel(hotel);
return `https://www.habbo.${h}/gamedata/furnidata_json/1`;
}
export function habboExternalTextsUrl(hotel: string): string {
const h = normalizeHabboGamedataHotel(hotel);
return `https://www.habbo.${h}/gamedata/external_flash_texts/1`;
}
export async function getHabboGamedataHotel(): Promise<HabboGamedataHotel> {
const { siteSettings } = await import("@/lib/services/site-settings");
const raw = await siteSettings.get(
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
DEFAULT_HABBO_GAMEDATA_HOTEL,
);
return normalizeHabboGamedataHotel(raw);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { getHabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getFurnitureDataPath } from "@/lib/services/furni-data"; import { getFurnitureDataPath } from "@/lib/services/furni-data";
@@ -128,6 +129,8 @@ export interface CatalogItemsData {
furniRevisionMap: Record<number, { classname: string; revision: number }>; furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[]; interactionTypes: string[];
allPages: { id: number; caption: string }[]; allPages: { id: number; caption: string }[];
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
gamedataHotel: string;
} }
export async function loadCatalogItemsData( export async function loadCatalogItemsData(
@@ -160,7 +163,7 @@ export async function loadCatalogItemsData(
clubOnly: String(r.club_only ?? "0"), clubOnly: String(r.club_only ?? "0"),
})); }));
const [allPages, interactionTypesRaw] = await Promise.all([ const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
prisma.catalogPages.findMany({ prisma.catalogPages.findMany({
orderBy: { caption: "asc" }, orderBy: { caption: "asc" },
select: { id: true, caption: true }, select: { id: true, caption: true },
@@ -168,6 +171,7 @@ export async function loadCatalogItemsData(
prisma.$queryRaw<Array<{ interaction_type: string }>>` prisma.$queryRaw<Array<{ interaction_type: string }>>`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`, `,
getHabboGamedataHotel(),
]); ]);
const interactionTypes = interactionTypesRaw.map((r) => const interactionTypes = interactionTypesRaw.map((r) =>
@@ -319,5 +323,6 @@ export async function loadCatalogItemsData(
furniRevisionMap, furniRevisionMap,
interactionTypes, interactionTypes,
allPages, allPages,
gamedataHotel,
}; };
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* Furniture search & listing — powered by official Habbo furnidata. * Furniture search & listing — powered by official Habbo furnidata.
* *
* Uses habbo.it/gamedata/furnidata_json as the single source of truth. * Hotel locale comes from CMS setting `habbo_gamedata_hotel`.
* No third-party APIs (habbofurni.com) needed. * No third-party APIs (habbofurni.com) needed.
*/ */
@@ -14,7 +14,7 @@ export type { FurniListResult, HabboFurniItem };
/** /**
* List furniture from official Habbo furnidata with optional search and pagination. * List furniture from official Habbo furnidata with optional search and pagination.
* Data comes from habbo.it/gamedata/furnidata_json/1 (cached 30 min). * Data comes from the configured hotel furnidata_json (cached 30 min).
*/ */
export async function listFurniture( export async function listFurniture(
options: { options: {
+1
View File
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
habbo_imaging_url: "/imaging", habbo_imaging_url: "/imaging",
logo_url: "", logo_url: "",
nitro_client_url: "", nitro_client_url: "",
habbo_gamedata_hotel: "it",
}; };
const CACHE_TTL_MS = 300_000; const CACHE_TTL_MS = 300_000;