Add multi-hotel Habbo gamedata locale in CMS settings.
Local Build and Deploy / deploy (push) Failing after 33s
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:
1 parent
7bc0845932
commit
d1baaf798a
15 files changed
+275
-65
No files matched your search
@@ -5,12 +5,32 @@ import { z } from "zod";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
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 { 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";
|
||||
|
||||
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({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
@@ -23,9 +43,9 @@ export const saveManagedSettings = adminAction(
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings).filter(([key]) =>
|
||||
managedKeySet.has(key),
|
||||
);
|
||||
const entries = Object.entries(ctx.data.settings)
|
||||
.filter(([key]) => managedKeySet.has(key))
|
||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
@@ -36,7 +56,12 @@ export const saveManagedSettings = adminAction(
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
||||
clearHabboItCache();
|
||||
clearBadgeCache();
|
||||
}
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ saved: entries.length });
|
||||
},
|
||||
);
|
||||
@@ -46,10 +71,14 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
const value = normalizeSettingValue(
|
||||
key,
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
@@ -59,7 +88,10 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.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") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -71,6 +103,7 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
@@ -82,5 +115,6 @@ export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
@@ -49,6 +49,7 @@ interface CatalogDetailTabsProps {
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
interactionTypes: string[];
|
||||
gamedataHotel: string;
|
||||
}
|
||||
|
||||
export function CatalogDetailTabs({
|
||||
@@ -64,6 +65,7 @@ export function CatalogDetailTabs({
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
interactionTypes,
|
||||
gamedataHotel,
|
||||
}: CatalogDetailTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="settings">
|
||||
@@ -119,6 +121,7 @@ export function CatalogDetailTabs({
|
||||
furniDataIdList={furniDataIdList}
|
||||
furniDescriptionMap={furniDescriptionMap}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
gamedataHotel={gamedataHotel}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { translateCatalogItems } from "@/actions/catalog-items";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
habboGamedataHotelLabel,
|
||||
habboGamedataHotelShort,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { FurniIcon } from "./catalog-items-table";
|
||||
|
||||
@@ -53,6 +58,8 @@ interface CatalogTranslateTabProps {
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
|
||||
/** CMS setting `habbo_gamedata_hotel` — which official Habbo locale to suggest from */
|
||||
gamedataHotel?: string;
|
||||
}
|
||||
|
||||
type FilterMode = "all" | "missing" | "modified";
|
||||
@@ -64,11 +71,20 @@ export function CatalogTranslateTab({
|
||||
furniDataIdList,
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap = {},
|
||||
gamedataHotel = "it",
|
||||
}: CatalogTranslateTabProps) {
|
||||
const router = useRouter();
|
||||
const { run, isPending } = useServerAction();
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||
const [activeHotel, setActiveHotel] = useState(() =>
|
||||
normalizeHabboGamedataHotel(gamedataHotel),
|
||||
);
|
||||
useEffect(() => {
|
||||
setActiveHotel(normalizeHabboGamedataHotel(gamedataHotel));
|
||||
}, [gamedataHotel]);
|
||||
const hotelShort = habboGamedataHotelShort(activeHotel);
|
||||
const hotelLabel = habboGamedataHotelLabel(activeHotel);
|
||||
|
||||
const furniDataIdSet = useMemo(
|
||||
() => new Set(furniDataIdList),
|
||||
@@ -186,6 +202,7 @@ export function CatalogTranslateTab({
|
||||
string,
|
||||
{ name: string; description: string }
|
||||
> = {};
|
||||
let suggestHotel = activeHotel;
|
||||
|
||||
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
|
||||
const chunk = classnames.slice(i, i + CHUNK_SIZE);
|
||||
@@ -194,9 +211,14 @@ export function CatalogTranslateTab({
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (typeof data.hotel === "string") {
|
||||
suggestHotel = normalizeHabboGamedataHotel(data.hotel);
|
||||
setActiveHotel(suggestHotel);
|
||||
}
|
||||
Object.assign(allSuggestions, data.suggestions || {});
|
||||
}
|
||||
}
|
||||
const suggestLabel = habboGamedataHotelLabel(suggestHotel);
|
||||
|
||||
// Apply suggestions to items that have a match (names + descriptions)
|
||||
let nameCount = 0;
|
||||
@@ -229,15 +251,15 @@ export function CatalogTranslateTab({
|
||||
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
|
||||
if (descCount > 0) parts.push(`${descCount} description(s)`);
|
||||
toast.success(
|
||||
`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`,
|
||||
`${parts.join(" + ")} suggested from ${suggestLabel}. Review and save.`,
|
||||
);
|
||||
} else {
|
||||
toast.info(
|
||||
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
||||
`No new suggestions from ${suggestLabel} (${Object.keys(allSuggestions).length} looked up, all already match).`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`HabboFurni error: ${(err as Error).message}`);
|
||||
toast.error(`Suggest error: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
}
|
||||
@@ -318,14 +340,15 @@ export function CatalogTranslateTab({
|
||||
size="sm"
|
||||
onClick={handleSuggestFromHabboFurni}
|
||||
disabled={isSuggesting || isPending || targetItems.length === 0}
|
||||
title="Suggest Italian names + descriptions from HabboFurni API (habbo.it)"
|
||||
title={`Suggest names + descriptions from ${hotelLabel}. Change hotel in Admin → Settings → Game assets.`}
|
||||
>
|
||||
{isSuggesting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
Suggest {hotelShort}
|
||||
{selected.size > 0 ? ` (${selected.size})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -47,6 +47,7 @@ export default async function CatalogEditPage({
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
gamedataHotel,
|
||||
} = itemsData;
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
@@ -124,6 +125,7 @@ export default async function CatalogEditPage({
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
allPages={allPages}
|
||||
interactionTypes={interactionTypes}
|
||||
gamedataHotel={gamedataHotel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
| "url"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "textarea";
|
||||
| "textarea"
|
||||
| "select";
|
||||
|
||||
export type SettingsGroupIcon =
|
||||
| "building"
|
||||
@@ -22,6 +25,7 @@ export interface ManagedField {
|
||||
type?: FieldType;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsGroup {
|
||||
@@ -150,6 +154,18 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
icon: "music",
|
||||
description: "Public URLs and overrides for SWF / Nitro asset packages.",
|
||||
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",
|
||||
label: "Song disks MP3 base URL",
|
||||
|
||||
@@ -197,6 +197,20 @@ function FieldControl({
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
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
|
||||
id={field.key}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiOk } from "@/lib/api-response";
|
||||
import { getHabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { batchLookupByClassnames } from "@/lib/services/habbofurni";
|
||||
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.CATALOG_VIEW },
|
||||
async (request) => {
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
const raw = request.nextUrl.searchParams.get("classnames") || "";
|
||||
const classnames = raw
|
||||
.split(",")
|
||||
@@ -13,6 +15,10 @@ export const GET = withAdmin(
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
|
||||
if (classnames.length === 0) {
|
||||
return apiOk({ hotel, suggestions: {} });
|
||||
}
|
||||
|
||||
const found = await batchLookupByClassnames(classnames);
|
||||
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 }>;
|
||||
interactionTypes: string[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
gamedataHotel?: string;
|
||||
}
|
||||
|
||||
export interface PageUpdateData {
|
||||
@@ -691,6 +692,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
gamedataHotel={itemsData.gamedataHotel}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { getHabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
|
||||
@@ -128,6 +129,8 @@ export interface CatalogItemsData {
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
interactionTypes: string[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
|
||||
gamedataHotel: string;
|
||||
}
|
||||
|
||||
export async function loadCatalogItemsData(
|
||||
@@ -160,7 +163,7 @@ export async function loadCatalogItemsData(
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
}));
|
||||
|
||||
const [allPages, interactionTypesRaw] = await Promise.all([
|
||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
@@ -168,6 +171,7 @@ export async function loadCatalogItemsData(
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||
`,
|
||||
getHabboGamedataHotel(),
|
||||
]);
|
||||
|
||||
const interactionTypes = interactionTypesRaw.map((r) =>
|
||||
@@ -319,5 +323,6 @@ export async function loadCatalogItemsData(
|
||||
furniRevisionMap,
|
||||
interactionTypes,
|
||||
allPages,
|
||||
gamedataHotel,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +1,61 @@
|
||||
/**
|
||||
* habbo.it furnidata cache — downloads once, keeps in memory.
|
||||
* Provides instant lookup by classname for enriching imports.
|
||||
* Official Habbo furnidata cache — hotel chosen in CMS settings
|
||||
* (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory.
|
||||
*/
|
||||
|
||||
export type { HabboItFurniEntry } from "@/types/furni";
|
||||
|
||||
import type { HabboItFurniEntry } from "@/types/furni";
|
||||
import {
|
||||
getHabboGamedataHotel,
|
||||
habboFurnidataUrl,
|
||||
type HabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
const FURNIDATA_URL = "https://www.habbo.it/gamedata/furnidata_json/1";
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
let cache: Map<string, HabboItFurniEntry> | null = null;
|
||||
let cacheHotel: HabboGamedataHotel | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null;
|
||||
|
||||
/**
|
||||
* Fetches and parses the full habbo.it furnidata JSON.
|
||||
* Results are cached in memory for 30 minutes.
|
||||
* Fetches and parses the full official Habbo furnidata JSON for the
|
||||
* configured hotel. Results are cached in memory for 30 minutes.
|
||||
*/
|
||||
export async function getHabboItFurnidata(): Promise<
|
||||
Map<string, HabboItFurniEntry>
|
||||
> {
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
const now = Date.now();
|
||||
|
||||
// Return cache if still valid
|
||||
if (cache && now - cacheTimestamp < CACHE_TTL) {
|
||||
if (
|
||||
cache &&
|
||||
cacheHotel === hotel &&
|
||||
now - cacheTimestamp < CACHE_TTL
|
||||
) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (loading) return loading;
|
||||
|
||||
loading = (async () => {
|
||||
const url = habboFurnidataUrl(hotel);
|
||||
try {
|
||||
console.debug("[habbo-it] Fetching furnidata from habbo.it...");
|
||||
const res = await fetch(FURNIDATA_URL, {
|
||||
console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[habbo-it] Fetch failed: ${res.status}`);
|
||||
console.warn(`[habbo-furnidata] Fetch failed: ${res.status}`);
|
||||
return cache || new Map();
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const map = new Map<string, HabboItFurniEntry>();
|
||||
|
||||
// Process room items (floor furniture)
|
||||
const roomTypes = data?.roomitemtypes?.furnitype;
|
||||
if (Array.isArray(roomTypes)) {
|
||||
for (const item of roomTypes) {
|
||||
@@ -78,7 +86,6 @@ export async function getHabboItFurnidata(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Process wall items
|
||||
const wallTypes = data?.wallitemtypes?.furnitype;
|
||||
if (Array.isArray(wallTypes)) {
|
||||
for (const item of wallTypes) {
|
||||
@@ -110,13 +117,17 @@ export async function getHabboItFurnidata(): Promise<
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
|
||||
`[habbo-furnidata] Cached ${map.size} items from habbo.${hotel} (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
|
||||
);
|
||||
cache = map;
|
||||
cacheTimestamp = now;
|
||||
cacheHotel = hotel;
|
||||
cacheTimestamp = Date.now();
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.warn("[habbo-it] Fetch error:", (err as Error).message);
|
||||
console.warn(
|
||||
"[habbo-furnidata] Fetch error:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return cache || new Map();
|
||||
} finally {
|
||||
loading = null;
|
||||
@@ -127,7 +138,7 @@ export async function getHabboItFurnidata(): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a single item by classname from habbo.it furnidata.
|
||||
* Lookup a single item by classname from official Habbo furnidata.
|
||||
* Tries exact match first, then base classname (without color variant).
|
||||
*/
|
||||
export async function lookupHabboIt(
|
||||
@@ -135,22 +146,17 @@ export async function lookupHabboIt(
|
||||
): Promise<HabboItFurniEntry | null> {
|
||||
const map = await getHabboItFurnidata();
|
||||
|
||||
// 1. Exact match
|
||||
const exact = map.get(classname);
|
||||
if (exact) return exact;
|
||||
|
||||
// 2. If classname has color variant (e.g. "chair*4"), try base
|
||||
if (classname.includes("*")) {
|
||||
const base = classname.split("*")[0];
|
||||
// Try base with *0 first (most common default)
|
||||
const variant0 = map.get(`${base}*0`);
|
||||
if (variant0) return variant0;
|
||||
// Try just the base
|
||||
const baseMatch = map.get(base);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
|
||||
// 3. If no variant in classname, try *0
|
||||
if (!classname.includes("*")) {
|
||||
const withVariant = map.get(`${classname}*0`);
|
||||
if (withVariant) return withVariant;
|
||||
@@ -159,8 +165,9 @@ export async function lookupHabboIt(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Clear the cache (useful after manual edits) */
|
||||
/** Clear the cache (e.g. after changing habbo_gamedata_hotel). */
|
||||
export function clearHabboItCache(): void {
|
||||
cache = null;
|
||||
cacheHotel = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Badge search — powered by official Habbo external texts.
|
||||
*
|
||||
* Uses habbo.it/gamedata/external_flash_texts as the single source.
|
||||
* Hotel locale comes from CMS setting `habbo_gamedata_hotel`.
|
||||
* Badge images from images.habbo.com/c_images/album1584/.
|
||||
* No third-party APIs (habboassets.com) needed.
|
||||
*/
|
||||
|
||||
export type { HabboAssetBadge } from "@/types/furni";
|
||||
|
||||
import type { HabboAssetBadge } from "@/types/furni";
|
||||
import {
|
||||
getHabboGamedataHotel,
|
||||
habboExternalTextsUrl,
|
||||
type HabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
const EXTERNAL_TEXTS_URL =
|
||||
"https://www.habbo.it/gamedata/external_flash_texts/1";
|
||||
const BADGE_IMAGE_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@@ -22,26 +24,36 @@ interface BadgeEntry {
|
||||
}
|
||||
|
||||
let badgeCache: BadgeEntry[] | null = null;
|
||||
let cacheHotel: HabboGamedataHotel | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
let loadingPromise: Promise<BadgeEntry[]> | null = null;
|
||||
|
||||
/**
|
||||
* Fetch and parse all badges from official Habbo external texts.
|
||||
* Cached in memory for 30 minutes.
|
||||
* Cached in memory for 30 minutes (per configured hotel).
|
||||
*/
|
||||
async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
async function loadBadges(): Promise<{
|
||||
badges: BadgeEntry[];
|
||||
hotel: HabboGamedataHotel;
|
||||
}> {
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
const now = Date.now();
|
||||
if (badgeCache && now - cacheTimestamp < CACHE_TTL) return badgeCache;
|
||||
if (loadingPromise) return loadingPromise;
|
||||
if (
|
||||
badgeCache &&
|
||||
cacheHotel === hotel &&
|
||||
now - cacheTimestamp < CACHE_TTL
|
||||
) {
|
||||
return { badges: badgeCache, hotel };
|
||||
}
|
||||
if (loadingPromise) {
|
||||
const badges = await loadingPromise;
|
||||
return { badges, hotel: cacheHotel ?? hotel };
|
||||
}
|
||||
|
||||
loadingPromise = (async () => {
|
||||
const url = habboExternalTextsUrl(hotel);
|
||||
try {
|
||||
if (!EXTERNAL_TEXTS_URL) {
|
||||
console.warn("[badges] HABBO_EXTERNAL_TEXTS_URL not configured");
|
||||
return badgeCache || [];
|
||||
}
|
||||
|
||||
const res = await fetch(EXTERNAL_TEXTS_URL, {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
|
||||
@@ -53,7 +65,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
const text = await res.text();
|
||||
const lines = text.split("\n");
|
||||
|
||||
// Parse key=value format, extract badge_name_CODE and badge_desc_CODE
|
||||
const nameMap = new Map<string, string>();
|
||||
const descMap = new Map<string, string>();
|
||||
|
||||
@@ -63,7 +74,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
const key = line.substring(0, eqIdx).trim();
|
||||
const value = line.substring(eqIdx + 1).trim();
|
||||
|
||||
// Format: badge_name_CODE or CODE_badge_name
|
||||
let match = key.match(/^badge_name_(.+)$/);
|
||||
if (match) {
|
||||
nameMap.set(match[1], value);
|
||||
@@ -86,7 +96,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into badge entries
|
||||
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()]);
|
||||
const badges: BadgeEntry[] = [];
|
||||
|
||||
@@ -98,14 +107,14 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort alphabetically by code
|
||||
badges.sort((a, b) => a.code.localeCompare(b.code));
|
||||
|
||||
console.debug(
|
||||
`[badges] Loaded ${badges.length} badges from external texts`,
|
||||
`[badges] Loaded ${badges.length} badges from habbo.${hotel}`,
|
||||
);
|
||||
badgeCache = badges;
|
||||
cacheTimestamp = now;
|
||||
cacheHotel = hotel;
|
||||
cacheTimestamp = Date.now();
|
||||
return badges;
|
||||
} catch (err) {
|
||||
console.warn("[badges] Load error:", (err as Error).message);
|
||||
@@ -115,7 +124,8 @@ async function loadBadges(): Promise<BadgeEntry[]> {
|
||||
}
|
||||
})();
|
||||
|
||||
return loadingPromise;
|
||||
const badges = await loadingPromise;
|
||||
return { badges, hotel: cacheHotel ?? hotel };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,9 +136,9 @@ export async function searchBadges(
|
||||
term: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
_hotel: string | null = null, // kept for API compat, ignored (single source)
|
||||
_hotel: string | null = null, // API compat; locale comes from CMS settings
|
||||
): Promise<HabboAssetBadge[]> {
|
||||
const badges = await loadBadges();
|
||||
const { badges, hotel } = await loadBadges();
|
||||
const search = term.toLowerCase().trim();
|
||||
|
||||
let filtered = badges;
|
||||
@@ -140,7 +150,6 @@ export async function searchBadges(
|
||||
b.description.toLowerCase().includes(search),
|
||||
);
|
||||
} else {
|
||||
// No search term: return newest (reverse sorted, last entries are newest)
|
||||
filtered = [...badges].reverse();
|
||||
}
|
||||
|
||||
@@ -151,14 +160,15 @@ export async function searchBadges(
|
||||
code: b.code,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
hotel: "it",
|
||||
hotel,
|
||||
url_habbo: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
|
||||
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`, // same source, no fallback needed
|
||||
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Clear the badge cache */
|
||||
export function clearBadgeCache(): void {
|
||||
badgeCache = null;
|
||||
cacheHotel = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ export type { FurniListResult, HabboFurniItem };
|
||||
|
||||
/**
|
||||
* 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(
|
||||
options: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
|
||||
habbo_imaging_url: "/imaging",
|
||||
logo_url: "",
|
||||
nitro_client_url: "",
|
||||
habbo_gamedata_hotel: "it",
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
|
||||
Reference in new issue
Block a user