diff --git a/src/i18n/commandocentrum-messages.test.ts b/src/i18n/commandocentrum-messages.test.ts new file mode 100644 index 0000000000..7aa48fdba0 --- /dev/null +++ b/src/i18n/commandocentrum-messages.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { createTranslator } from "next-intl"; +import { describe, expect, it } from "vitest"; +import en from "@/messages/en.json"; +import itMessages from "@/messages/it.json"; +import nl from "@/messages/nl.json"; +import { mergeMessages } from "./merge-messages"; + +const namespace = "pages.admin.commandocentrum"; +const keys = new Set( + ["page.tsx", "commandocentrum-controls.tsx"].flatMap((file) => + Array.from( + readFileSync(`src/app/admin/commandocentrum/${file}`, "utf8").matchAll( + /\bt\("([^"]+)"/g, + ), + (match) => match[1], + ), + ), +); +for (const group of ["hotel", "players", "rewards", "advanced"]) + keys.add(`groups.${group}`); + +describe("command center translations", () => { + it.each([ + ["en", en], + ["it", itMessages], + ] as const)("provides every rendered message in %s", (locale, messages) => { + const errors: unknown[] = []; + const t = createTranslator({ + locale, + messages: messages as typeof en, + namespace, + onError: (error) => errors.push(error), + }); + for (const key of keys) { + expect(t.has(key as Parameters[0]), key).toBe(true); + expect( + t(key as Parameters[0], { count: 42, time: "12:34" }), + key, + ).not.toBe(""); + } + expect(errors).toEqual([]); + }); + it("supplies nested fallback messages to a client translator and formats values", () => { + const messages = mergeMessages(en, nl) as typeof en; + const t = createTranslator({ locale: "nl", messages, namespace }); + expect(t("title")).toBe(nl.pages.admin.commandocentrum.title); + expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts"); + expect(t("groups.players")).toBe("Players"); + expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34"); + expect(nl.pages.admin.commandocentrum).not.toHaveProperty("totalAccounts"); + }); +}); diff --git a/src/i18n/merge-messages.ts b/src/i18n/merge-messages.ts new file mode 100644 index 0000000000..442563c042 --- /dev/null +++ b/src/i18n/merge-messages.ts @@ -0,0 +1,17 @@ +export type MessageTree = { [key: string]: string | MessageTree }; + +/** Fill missing translations before sending messages to server and client consumers. */ +export function mergeMessages( + fallback: MessageTree, + localized: MessageTree, +): MessageTree { + const result: MessageTree = { ...fallback }; + for (const [key, value] of Object.entries(localized)) { + const base = fallback[key]; + result[key] = + typeof value === "object" && typeof base === "object" + ? mergeMessages(base, value) + : value; + } + return result; +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 13878d2a8a..4b295fcc48 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,5 +1,6 @@ import { cookies, headers } from "next/headers"; import { getRequestConfig } from "next-intl/server"; +import { mergeMessages } from "./merge-messages"; // i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then // the Accept-Language header, and finally English as fallback. @@ -83,11 +84,19 @@ export default getRequestConfig(async () => { return { locale, - messages, + messages: mergeMessages(fallback, messages), // Never throw on a missing message — use the English value, or a humanised // key as a last resort. - getMessageFallback({ key }: { key: string }) { - const fromEnglish = key + getMessageFallback({ + namespace, + key, + }: { + namespace?: string; + key: string; + }) { + const fromEnglish = [namespace, key] + .filter(Boolean) + .join(".") .split(".") .reduce( (o, k) => diff --git a/src/messages/en.json b/src/messages/en.json index 9567ea4837..e43237eb14 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2449,7 +2449,101 @@ "readFailed": "Data could not be loaded", "emulatorConfig": "Emulator connection", "emailConfig": "Email delivery", - "configured": "Configured" + "configured": "Configured", + "playersOnline": "Players online", + "totalAccounts": "{count} total accounts", + "dbUnavailable": "Database unavailable", + "emulator": "Emulator", + "online": "Online", + "offline": "Offline", + "rconReachable": "Emulator connection available", + "rconNotResponding": "Emulator is not responding", + "database": "Database", + "connected": "Connected", + "down": "Unavailable", + "queriesResponding": "Database queries are responding", + "cannotReachDb": "Cannot connect to the database", + "emulatorErrors": "Emulator errors", + "last50Logged": "Up to 50 latest records", + "serverInfo": "Server information", + "node": "Node.js", + "platform": "Platform", + "appUptime": "Application uptime", + "heapUsed": "Heap memory used", + "rss": "Process memory (RSS)", + "hostUptime": "Host uptime", + "load1m": "Load average (1 min)", + "cpus": "Logical CPUs", + "systemDiagnostics": "System diagnostics", + "connectionHealthy": "Connection is healthy", + "unreachable": "Unreachable", + "emulatorRcon": "Emulator connection", + "respondingToPing": "Responding to health checks", + "noResponse": "No response", + "notConfigured": "Not configured", + "twoFaEncryptionKey": "2FA encryption key", + "appKeyConfigured": "Encryption key configured", + "appKeyMissing": "Encryption key missing", + "maintenanceMode": "Maintenance mode", + "siteIsInMaintenance": "The site is in maintenance mode", + "siteIsLive": "The site is open", + "recentStaffActivity": "Recent staff activity", + "noStaffActivity": "No recent staff activity", + "colWhen": "Date and time", + "colStaff": "Staff member", + "colAction": "Action", + "colDescription": "Description", + "recentEmulatorErrors": "Recent emulator errors", + "noEmulatorErrors": "No emulator errors recorded", + "colType": "Type", + "colVersion": "Version", + "colStacktrace": "Error details", + "truncated": "Further lines omitted", + "empty": "No details available", + "defaultGiftMessage": "A gift for you!", + "emulatorControls": "Emulator commands", + "updateCatalog": "Reload catalog", + "updateCatalogDesc": "Reload the catalog from the database.", + "updateWordFilter": "Reload word filter", + "updateWordFilterDesc": "Apply the latest word filter settings.", + "updateNavigator": "Reload navigator", + "updateNavigatorDesc": "Reload the room navigator data.", + "hotelAlert": "Hotel announcement", + "hotelAlertDesc": "Send a message to all online players.", + "messageToBroadcast": "Message for all players", + "sendAlert": "Send announcement", + "disconnectDesc": "Disconnect the selected player from the hotel.", + "userId": "User ID", + "usernameRequired": "Username (required)", + "alertDesc": "Send a private alert to a player.", + "alertMessage": "Alert message", + "forwardToRoom": "Move player to room", + "forwardDesc": "Send the selected player to a specific room.", + "roomId": "Room ID", + "forwardUser": "Move player", + "giveCredits": "Give credits", + "giveCreditsDesc": "Add credits to the selected player’s balance.", + "amount": "Amount", + "giveDuckets": "Give duckets", + "giveDucketsDesc": "Add duckets to the selected player’s balance.", + "giveDiamonds": "Give diamonds", + "giveDiamondsDesc": "Add diamonds to the selected player’s balance.", + "giveBadge": "Give badge", + "giveBadgeDesc": "Give a badge to the selected player.", + "badgeCode": "Badge code", + "setMotto": "Change motto", + "setMottoDesc": "Update the selected player’s motto.", + "newMotto": "New motto", + "setRank": "Change rank", + "setRankDesc": "Update the selected player’s rank.", + "rankPlaceholder": "Rank (1–10)", + "executeCommand": "Execute emulator command", + "executeCommandDesc": "Execute a command for the selected player.", + "commandPlaceholder": "Command", + "sendGift": "Send gift", + "sendGiftDesc": "Send a furniture item as a gift to the selected player.", + "furniId": "Furniture ID", + "giftMessage": "Gift message" }, "emulator": { "title": "Emulator Config", diff --git a/src/messages/it.json b/src/messages/it.json index 59905ec00d..a78629d5f9 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -2331,7 +2331,101 @@ "readFailed": "Impossibile caricare i dati", "emulatorConfig": "Connessione emulatore", "emailConfig": "Invio email", - "configured": "Configurato" + "configured": "Configurato", + "playersOnline": "Giocatori online", + "totalAccounts": "{count} account totali", + "dbUnavailable": "Database non disponibile", + "emulator": "Emulatore", + "online": "Online", + "offline": "Offline", + "rconReachable": "Connessione all’emulatore disponibile", + "rconNotResponding": "L’emulatore non risponde", + "database": "Database", + "connected": "Connesso", + "down": "Non disponibile", + "queriesResponding": "Il database risponde alle richieste", + "cannotReachDb": "Impossibile connettersi al database", + "emulatorErrors": "Errori emulatore", + "last50Logged": "Fino agli ultimi 50 eventi registrati", + "serverInfo": "Informazioni server", + "node": "Node.js", + "platform": "Piattaforma", + "appUptime": "Tempo di attività applicazione", + "heapUsed": "Memoria heap utilizzata", + "rss": "Memoria processo (RSS)", + "hostUptime": "Tempo di attività server", + "load1m": "Carico medio (1 min)", + "cpus": "CPU logiche", + "systemDiagnostics": "Diagnostica sistema", + "connectionHealthy": "Connessione funzionante", + "unreachable": "Non raggiungibile", + "emulatorRcon": "Connessione emulatore", + "respondingToPing": "Risponde ai controlli di stato", + "noResponse": "Nessuna risposta", + "notConfigured": "Non configurato", + "twoFaEncryptionKey": "Chiave di cifratura 2FA", + "appKeyConfigured": "Chiave di cifratura configurata", + "appKeyMissing": "Chiave di cifratura mancante", + "maintenanceMode": "Modalità manutenzione", + "siteIsInMaintenance": "Il sito è in manutenzione", + "siteIsLive": "Il sito è aperto", + "recentStaffActivity": "Attività recenti dello staff", + "noStaffActivity": "Nessuna attività recente dello staff", + "colWhen": "Data e ora", + "colStaff": "Membro dello staff", + "colAction": "Azione", + "colDescription": "Descrizione", + "recentEmulatorErrors": "Errori recenti dell’emulatore", + "noEmulatorErrors": "Nessun errore dell’emulatore registrato", + "colType": "Tipo", + "colVersion": "Versione", + "colStacktrace": "Dettagli errore", + "truncated": "Ulteriori righe omesse", + "empty": "Nessun dettaglio disponibile", + "defaultGiftMessage": "Un regalo per te!", + "emulatorControls": "Comandi emulatore", + "updateCatalog": "Ricarica catalogo", + "updateCatalogDesc": "Ricarica il catalogo dal database.", + "updateWordFilter": "Ricarica filtro parole", + "updateWordFilterDesc": "Applica le ultime impostazioni del filtro parole.", + "updateNavigator": "Ricarica navigatore", + "updateNavigatorDesc": "Ricarica i dati del navigatore stanze.", + "hotelAlert": "Avviso hotel", + "hotelAlertDesc": "Invia un messaggio a tutti i giocatori online.", + "messageToBroadcast": "Messaggio per tutti i giocatori", + "sendAlert": "Invia avviso", + "disconnectDesc": "Disconnetti il giocatore selezionato dall’hotel.", + "userId": "ID utente", + "usernameRequired": "Nome utente (obbligatorio)", + "alertDesc": "Invia un avviso privato a un giocatore.", + "alertMessage": "Messaggio di avviso", + "forwardToRoom": "Sposta giocatore in stanza", + "forwardDesc": "Invia il giocatore selezionato in una stanza specifica.", + "roomId": "ID stanza", + "forwardUser": "Sposta giocatore", + "giveCredits": "Assegna crediti", + "giveCreditsDesc": "Aggiungi crediti al saldo del giocatore selezionato.", + "amount": "Quantità", + "giveDuckets": "Assegna duckets", + "giveDucketsDesc": "Aggiungi duckets al saldo del giocatore selezionato.", + "giveDiamonds": "Assegna diamanti", + "giveDiamondsDesc": "Aggiungi diamanti al saldo del giocatore selezionato.", + "giveBadge": "Assegna distintivo", + "giveBadgeDesc": "Assegna un distintivo al giocatore selezionato.", + "badgeCode": "Codice distintivo", + "setMotto": "Modifica motto", + "setMottoDesc": "Aggiorna il motto del giocatore selezionato.", + "newMotto": "Nuovo motto", + "setRank": "Modifica rank", + "setRankDesc": "Aggiorna il rank del giocatore selezionato.", + "rankPlaceholder": "Rank (1–10)", + "executeCommand": "Esegui comando emulatore", + "executeCommandDesc": "Esegui un comando per il giocatore selezionato.", + "commandPlaceholder": "Comando", + "sendGift": "Invia regalo", + "sendGiftDesc": "Invia un furno in regalo al giocatore selezionato.", + "furniId": "ID furno", + "giftMessage": "Messaggio del regalo" }, "emulator": { "title": "Configurazione emulatore",