-
Status: {{ session.session.status }} · round {{ session.session.current_round }}
-
Round question id: {{ roundQuestionId || '-' }}
-
Prompt: {{ session.round_question.prompt }}
+
{{ copy('common.status') }}: {{ session.session.status }} · {{ copy('common.round') }} {{ session.session.current_round }}
+
{{ copy('common.round_question_id') }}: {{ roundQuestionId || '-' }}
+
{{ copy('common.prompt') }}: {{ session.round_question.prompt }}
- {{ p.nickname }}: {{ p.score }}
{{ scoreboardPayload }}
-
Final leaderboard
-
Winner: {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)
+
{{ copy('host.final_leaderboard') }}
+
{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)
- {{ entry.nickname }}: {{ entry.score }}
@@ -62,7 +64,10 @@ type LeaderboardResponse = FinishGameResponse;
`,
})
-export class HostShellComponent implements OnInit {
+export class HostShellComponent implements OnInit, OnDestroy {
+ locale = resolvePreferredLocale();
+ readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
+
sessionCode = '';
categorySlug = 'general';
roundQuestionId = '';
@@ -79,8 +84,12 @@ export class HostShellComponent implements OnInit {
private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api);
+ private unsubscribeLocale: (() => void) | null = null;
ngOnInit(): void {
+ this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
+ this.locale = locale;
+ });
if (typeof window === 'undefined') {
return;
}
@@ -100,6 +109,15 @@ export class HostShellComponent implements OnInit {
void this.refreshSession();
}
+ ngOnDestroy(): void {
+ this.unsubscribeLocale?.();
+ this.unsubscribeLocale = null;
+ }
+
+ copy(key: string): string {
+ return t(key, this.locale);
+ }
+
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
@@ -149,7 +167,7 @@ export class HostShellComponent implements OnInit {
}
this.syncRouteFromSession();
} catch (error) {
- this.error = `Session refresh failed: ${(error as Error).message}`;
+ this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
@@ -207,7 +225,7 @@ export class HostShellComponent implements OnInit {
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
- this.scoreboardError = `Scoreboard failed: ${(error as Error).message}`;
+ this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
@@ -220,14 +238,14 @@ export class HostShellComponent implements OnInit {
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
- throw new Error('Session code is required');
+ throw new Error(this.copy('host.session_code_required'));
}
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.scoreboardPayload = '';
this.resetFinalLeaderboard();
await this.refreshSession();
} catch (error) {
- this.nextRoundError = `Next round failed: ${(error as Error).message}`;
+ this.nextRoundError = `${this.copy('host.next_round_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
@@ -240,7 +258,7 @@ export class HostShellComponent implements OnInit {
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
- throw new Error('Session code is required');
+ throw new Error(this.copy('host.session_code_required'));
}
const payload = await this.request
(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {});
this.finalLeaderboardPayload = JSON.stringify(payload, null, 2);
@@ -253,7 +271,7 @@ export class HostShellComponent implements OnInit {
this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null;
await this.refreshSession();
} catch (error) {
- this.finishError = `Finish game failed: ${(error as Error).message}`;
+ this.finishError = `${this.copy('host.finish_game_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts
index bc01df3..3d4a099 100644
--- a/frontend/angular/src/app/features/player/player-shell.component.ts
+++ b/frontend/angular/src/app/features/player/player-shell.component.ts
@@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
+import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
@@ -27,35 +28,35 @@ function resolveLocalStorage(): Storage | undefined {
standalone: true,
imports: [CommonModule, FormsModule],
template: `
- Player SPA gameplay flow
+ {{ copy('player.title') }}
-
-
-
-
-
+
+
+
+
+
- Reconnecting… trying to refresh session state.
-
-
+ {{ copy('player.reconnecting_text') }}
+
+
- You are offline. Reconnect to continue gameplay.
-
-
+ {{ copy('player.offline_text') }}
+
+
{{ loadingMessage }}
-
Status: {{ session.session.status }}
-
Prompt: {{ session.round_question.prompt }}
+
{{ copy('common.status') }}: {{ session.session.status }}
+
{{ copy('common.prompt') }}: {{ session.round_question.prompt }}
-
-
-
+
+
+
-
-
+
+
-
Final leaderboard
+
{{ copy('player.final_leaderboard') }}
- {{ entry.nickname }}: {{ entry.score }}
@@ -84,12 +85,15 @@ function resolveLocalStorage(): Storage | undefined {
{{ submitError.message }}
-
-
+
+
`,
})
export class PlayerShellComponent implements OnInit, OnDestroy {
+ locale = resolvePreferredLocale();
+ readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
+
sessionCode = '';
nickname = '';
playerId = 0;
@@ -108,6 +112,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType
| null = null;
private stateSyncTimer: ReturnType | null = null;
+ private unsubscribeLocale: (() => void) | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -121,6 +126,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
+ this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
+ this.locale = locale;
+ });
+
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
@@ -147,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
this.clearReconnectTimer();
this.clearStateSyncTimer();
+ this.unsubscribeLocale?.();
+ this.unsubscribeLocale = null;
}
private readonly handleOnline = (): void => {
@@ -198,17 +209,21 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
get loadingMessage(): string {
switch (this.loadingTransition) {
case 'join':
- return 'Joining session… restoring your player state.';
+ return this.copy('player.loading_join');
case 'submit-lie':
- return 'Submitting lie… waiting for guess phase.';
+ return this.copy('player.loading_submit_lie');
case 'submit-guess':
- return 'Submitting guess… waiting for reveal.';
+ return this.copy('player.loading_submit_guess');
case 'refresh':
default:
- return 'Loading latest session state…';
+ return this.copy('player.loading_refresh');
}
}
+ copy(key: string): string {
+ return t(key, this.locale);
+ }
+
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
@@ -352,7 +367,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.syncRouteFromSession();
this.markOnline();
} catch (error) {
- this.error = `Session refresh failed: ${this.toMessage(error)}`;
+ this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -382,7 +397,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.syncRouteFromSession();
this.markOnline();
} catch (error) {
- this.error = `Join failed: ${this.toMessage(error)}`;
+ this.error = `${this.copy('player.join_failed')}: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -410,7 +425,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
await this.refreshSession();
this.markOnline();
} catch (error) {
- this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` };
+ this.submitError = { kind: 'lie', message: `${this.copy('player.lie_submit_failed')}: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -438,7 +453,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
await this.refreshSession();
this.markOnline();
} catch (error) {
- this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` };
+ this.submitError = { kind: 'guess', message: `${this.copy('player.guess_submit_failed')}: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;
diff --git a/frontend/angular/src/app/lobby-i18n.spec.ts b/frontend/angular/src/app/lobby-i18n.spec.ts
new file mode 100644
index 0000000..8ba00b2
--- /dev/null
+++ b/frontend/angular/src/app/lobby-i18n.spec.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+type StorageLike = {
+ getItem: (key: string) => string | null;
+ setItem: (key: string, value: string) => void;
+};
+
+function storageMock(initial: Record = {}): StorageLike {
+ const data = new Map(Object.entries(initial));
+ return {
+ getItem: vi.fn((key: string) => data.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ data.set(key, value);
+ }),
+ };
+}
+
+describe('lobby i18n locale propagation', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ });
+
+ it('notifies subscribers immediately and on locale changes', async () => {
+ const localStorage = storageMock({ 'wpp.locale': 'en' });
+ vi.stubGlobal('window', {
+ location: { search: '' },
+ localStorage,
+ });
+ vi.stubGlobal('navigator', { language: 'en-US' });
+
+ const i18n = await import('./lobby-i18n');
+
+ const updates: string[] = [];
+ const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
+
+ expect(updates).toEqual(['en']);
+
+ i18n.setPreferredLocale('da');
+ expect(updates).toEqual(['en', 'da']);
+
+ unsubscribe();
+ i18n.setPreferredLocale('en');
+ expect(updates).toEqual(['en', 'da']);
+ });
+});
diff --git a/frontend/angular/src/app/lobby-i18n.ts b/frontend/angular/src/app/lobby-i18n.ts
new file mode 100644
index 0000000..1637560
--- /dev/null
+++ b/frontend/angular/src/app/lobby-i18n.ts
@@ -0,0 +1,86 @@
+import lobbyCatalog from '../../../../shared/i18n/lobby.json';
+
+type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
+
+const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
+const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
+
+let activeLocale: SupportedLocale | null = null;
+const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
+
+export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
+ const locale = (rawLocale ?? '').trim().toLowerCase();
+ if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
+ return locale as SupportedLocale;
+ }
+
+ const shortLocale = locale.split('-')[0] ?? '';
+ if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
+ return shortLocale as SupportedLocale;
+ }
+
+ return DEFAULT_LOCALE;
+}
+
+export function resolvePreferredLocale(): SupportedLocale {
+ if (activeLocale) {
+ return activeLocale;
+ }
+
+ if (typeof window === 'undefined') {
+ activeLocale = DEFAULT_LOCALE;
+ return activeLocale;
+ }
+
+ const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
+ const storedLocale = window.localStorage?.getItem?.('wpp.locale');
+ const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
+
+ activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
+ return activeLocale;
+}
+
+export function setPreferredLocale(locale: string): SupportedLocale {
+ const normalized = normalizeLocale(locale);
+ activeLocale = normalized;
+ if (typeof window !== 'undefined') {
+ window.localStorage?.setItem?.('wpp.locale', normalized);
+ }
+
+ for (const subscriber of localeSubscribers) {
+ subscriber(normalized);
+ }
+
+ return normalized;
+}
+
+export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
+ localeSubscribers.add(callback);
+ callback(resolvePreferredLocale());
+ return () => {
+ localeSubscribers.delete(callback);
+ };
+}
+
+export function t(key: string, locale: string): string {
+ const normalizedLocale = normalizeLocale(locale);
+ const fallbackLocale = DEFAULT_LOCALE;
+ const segments = key.split('.');
+
+ let cursor: unknown = lobbyCatalog.frontend.ui;
+ for (const segment of segments) {
+ if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record))) {
+ return key;
+ }
+ cursor = (cursor as Record)[segment];
+ }
+
+ if (!cursor || typeof cursor !== 'object') {
+ return key;
+ }
+
+ const translations = cursor as Record;
+ return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
+}
+
+export const clientHasNoAudioOutput = Boolean(lobbyCatalog.frontend.capabilities.client_has_no_audio_output);
diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json
index 1077e6d..c02c50c 100644
--- a/shared/i18n/lobby.json
+++ b/shared/i18n/lobby.json
@@ -1,7 +1,10 @@
{
"locales": {
"default": "en",
- "supported": ["en", "da"]
+ "supported": [
+ "en",
+ "da"
+ ]
},
"frontend": {
"errors": {
@@ -37,6 +40,227 @@
"en": "Action failed. Refresh status and try again.",
"da": "Handlingen fejlede. Opdater status og prøv igen."
}
+ },
+ "ui": {
+ "common": {
+ "refresh": {
+ "en": "Refresh",
+ "da": "Opdatér"
+ },
+ "retry": {
+ "en": "Retry",
+ "da": "Prøv igen"
+ },
+ "back_to_join": {
+ "en": "Back to join",
+ "da": "Tilbage til join"
+ },
+ "session_code": {
+ "en": "Session code",
+ "da": "Sessionskode"
+ },
+ "status": {
+ "en": "Status",
+ "da": "Status"
+ },
+ "prompt": {
+ "en": "Prompt",
+ "da": "Spørgsmål"
+ },
+ "round_question_id": {
+ "en": "Round question id",
+ "da": "Rundespørgsmål-id"
+ },
+ "round": {
+ "en": "round",
+ "da": "runde"
+ }
+ },
+ "app": {
+ "title": {
+ "en": "WPP Angular Shell",
+ "da": "WPP Angular Shell"
+ },
+ "host_nav": {
+ "en": "Host",
+ "da": "Vært"
+ },
+ "player_nav": {
+ "en": "Player",
+ "da": "Spiller"
+ },
+ "language_label": {
+ "en": "Language",
+ "da": "Sprog"
+ }
+ },
+ "host": {
+ "title": {
+ "en": "Host gameplay flow",
+ "da": "Vært gameplay-flow"
+ },
+ "category": {
+ "en": "Category",
+ "da": "Kategori"
+ },
+ "start_round": {
+ "en": "Start round",
+ "da": "Start runde"
+ },
+ "show_question": {
+ "en": "Show question",
+ "da": "Vis spørgsmål"
+ },
+ "mix_answers": {
+ "en": "Mix answers → guess",
+ "da": "Bland svar → gæt"
+ },
+ "calculate_scores": {
+ "en": "Calculate scores → reveal",
+ "da": "Udregn score → afslør"
+ },
+ "load_scoreboard": {
+ "en": "Load scoreboard",
+ "da": "Hent scoreboard"
+ },
+ "start_next_round": {
+ "en": "Start next round",
+ "da": "Start næste runde"
+ },
+ "finish_game": {
+ "en": "Finish game",
+ "da": "Afslut spil"
+ },
+ "retry_scoreboard": {
+ "en": "Retry scoreboard",
+ "da": "Prøv scoreboard igen"
+ },
+ "retry_next_round": {
+ "en": "Retry next round",
+ "da": "Prøv næste runde igen"
+ },
+ "retry_finish": {
+ "en": "Retry finish game",
+ "da": "Prøv afslutning igen"
+ },
+ "session_refresh_failed": {
+ "en": "Session refresh failed",
+ "da": "Kunne ikke opdatere session"
+ },
+ "scoreboard_failed": {
+ "en": "Scoreboard failed",
+ "da": "Scoreboard fejlede"
+ },
+ "next_round_failed": {
+ "en": "Next round failed",
+ "da": "Næste runde fejlede"
+ },
+ "finish_game_failed": {
+ "en": "Finish game failed",
+ "da": "Afslutning fejlede"
+ },
+ "session_code_required": {
+ "en": "Session code is required",
+ "da": "Sessionskode er påkrævet"
+ },
+ "final_leaderboard": {
+ "en": "Final leaderboard",
+ "da": "Finale leaderboard"
+ },
+ "winner": {
+ "en": "Winner",
+ "da": "Vinder"
+ },
+ "audio_locale_hint": {
+ "en": "Host locale for audio references",
+ "da": "Værtens locale for lydreferencer"
+ }
+ },
+ "player": {
+ "title": {
+ "en": "Player gameplay flow",
+ "da": "Spiller gameplay-flow"
+ },
+ "nickname": {
+ "en": "Nickname",
+ "da": "Kaldenavn"
+ },
+ "join": {
+ "en": "Join",
+ "da": "Join"
+ },
+ "lie_label": {
+ "en": "Lie",
+ "da": "Løgn"
+ },
+ "submit_lie": {
+ "en": "Submit lie",
+ "da": "Send løgn"
+ },
+ "retry_lie_submit": {
+ "en": "Retry lie submit",
+ "da": "Prøv løgn igen"
+ },
+ "submit_guess": {
+ "en": "Submit guess",
+ "da": "Send gæt"
+ },
+ "retry_guess_submit": {
+ "en": "Retry guess submit",
+ "da": "Prøv gæt igen"
+ },
+ "final_leaderboard": {
+ "en": "Final leaderboard",
+ "da": "Finale leaderboard"
+ },
+ "reconnecting_text": {
+ "en": "Reconnecting… trying to refresh session state.",
+ "da": "Forbinder igen… prøver at opdatere session."
+ },
+ "offline_text": {
+ "en": "You are offline. Reconnect to continue gameplay.",
+ "da": "Du er offline. Forbind igen for at fortsætte."
+ },
+ "retry_now": {
+ "en": "Retry now",
+ "da": "Prøv nu"
+ },
+ "loading_refresh": {
+ "en": "Loading latest session state…",
+ "da": "Indlæser seneste session-status…"
+ },
+ "loading_join": {
+ "en": "Joining session… restoring your player state.",
+ "da": "Joiner session… gendanner spillerstatus."
+ },
+ "loading_submit_lie": {
+ "en": "Submitting lie… waiting for guess phase.",
+ "da": "Sender løgn… venter på gættefase."
+ },
+ "loading_submit_guess": {
+ "en": "Submitting guess… waiting for reveal.",
+ "da": "Sender gæt… venter på afsløring."
+ },
+ "session_refresh_failed": {
+ "en": "Session refresh failed",
+ "da": "Kunne ikke opdatere session"
+ },
+ "join_failed": {
+ "en": "Join failed",
+ "da": "Join fejlede"
+ },
+ "lie_submit_failed": {
+ "en": "Lie submit failed",
+ "da": "Løgn-fejl"
+ },
+ "guess_submit_failed": {
+ "en": "Guess submit failed",
+ "da": "Gætte-fejl"
+ }
+ }
+ },
+ "capabilities": {
+ "client_has_no_audio_output": true
}
},
"backend": {
@@ -90,4 +314,4 @@
}
}
}
-}
+}
\ No newline at end of file