From dd3b48067a8d44481afa92ddff3cb9bdfba6f629 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 19:27:15 +0000 Subject: [PATCH 1/2] feat(i18n): bind angular host/player copy to shared locale catalog --- frontend/angular/src/app/app.component.css | 3 +- frontend/angular/src/app/app.component.html | 15 +- frontend/angular/src/app/app.component.ts | 15 +- .../app/features/host/host-shell.component.ts | 61 +++-- .../features/player/player-shell.component.ts | 68 +++--- frontend/angular/src/app/lobby-i18n.ts | 63 +++++ shared/i18n/lobby.json | 228 +++++++++++++++++- 7 files changed, 389 insertions(+), 64 deletions(-) create mode 100644 frontend/angular/src/app/lobby-i18n.ts diff --git a/frontend/angular/src/app/app.component.css b/frontend/angular/src/app/app.component.css index 554d8a8..1e80afb 100644 --- a/frontend/angular/src/app/app.component.css +++ b/frontend/angular/src/app/app.component.css @@ -1,4 +1,5 @@ .shell { font-family: Arial, sans-serif; margin: 1rem; } -.shell__header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; } +.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; } .shell__header nav { display: flex; gap: 0.75rem; } .shell__content { margin-top: 1rem; } +.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; } diff --git a/frontend/angular/src/app/app.component.html b/frontend/angular/src/app/app.component.html index 6cce7b9..b628c8b 100644 --- a/frontend/angular/src/app/app.component.html +++ b/frontend/angular/src/app/app.component.html @@ -1,13 +1,20 @@
-

WPP Angular Shell

+

{{ copy('app.title') }}

+
-
+
diff --git a/frontend/angular/src/app/app.component.ts b/frontend/angular/src/app/app.component.ts index 709cb9a..109c09a 100644 --- a/frontend/angular/src/app/app.component.ts +++ b/frontend/angular/src/app/app.component.ts @@ -1,20 +1,33 @@ import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { Router, RouterLink, RouterOutlet } from '@angular/router'; +import { resolvePreferredLocale, setPreferredLocale, t } from './lobby-i18n'; + @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, RouterLink], + imports: [RouterOutlet, RouterLink, FormsModule], templateUrl: './app.component.html', styleUrl: './app.component.css', }) export class AppComponent { private readonly router = inject(Router); + locale = resolvePreferredLocale(); + constructor() { const shellRoute = document.body.dataset['wppShellRoute']; if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) { void this.router.navigateByUrl(shellRoute); } } + + copy(key: string): string { + return t(key, this.locale); + } + + setLocale(locale: string): void { + this.locale = setPreferredLocale(locale); + } } diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 6c193f6..48a1a9b 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -20,40 +21,41 @@ type LeaderboardResponse = FinishGameResponse; standalone: true, imports: [CommonModule, FormsModule], template: ` -

Host SPA gameplay flow

+

{{ copy('host.title') }}

-
- - - - - - - - - - - - - +
+ + + + + + + + + + + + +
+

{{ copy('host.audio_locale_hint') }}: {{ locale }}

{{ error }}

{{ scoreboardError }}

{{ nextRoundError }}

{{ finishError }}

-

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)

  1. {{ entry.nickname }}: {{ entry.score }}
@@ -63,6 +65,9 @@ type LeaderboardResponse = FinishGameResponse; `, }) export class HostShellComponent implements OnInit { + locale = resolvePreferredLocale(); + readonly clientHasNoAudioOutput = clientHasNoAudioOutput; + sessionCode = ''; categorySlug = 'general'; roundQuestionId = ''; @@ -100,6 +105,10 @@ export class HostShellComponent implements OnInit { void this.refreshSession(); } + copy(key: string): string { + return t(key, this.locale); + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } @@ -149,7 +158,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 +216,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 +229,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 +249,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 +262,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..d768eab 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, 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') }}

  1. {{ 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; @@ -198,17 +202,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 +360,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 +390,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 +418,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 +446,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.ts b/frontend/angular/src/app/lobby-i18n.ts new file mode 100644 index 0000000..56b8d50 --- /dev/null +++ b/frontend/angular/src/app/lobby-i18n.ts @@ -0,0 +1,63 @@ +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[]; + +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 (typeof window === 'undefined') { + return DEFAULT_LOCALE; + } + + const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); + const storedLocale = window.localStorage?.getItem?.('wpp.locale'); + const browserLocale = typeof navigator !== 'undefined' ? navigator.language : ''; + + return normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); +} + +export function setPreferredLocale(locale: string): SupportedLocale { + const normalized = normalizeLocale(locale); + if (typeof window !== 'undefined') { + window.localStorage?.setItem?.('wpp.locale', normalized); + } + return normalized; +} + +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 -- 2.39.5 From f3bd071322801a79d2926c765e44a9179a84ed6d Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 19:31:53 +0000 Subject: [PATCH 2/2] fix(frontend): propagate locale changes reactively to mounted shells --- .../app/features/host/host-shell.component.ts | 15 ++++-- .../features/player/player-shell.component.ts | 9 +++- frontend/angular/src/app/lobby-i18n.spec.ts | 47 +++++++++++++++++++ frontend/angular/src/app/lobby-i18n.ts | 27 ++++++++++- 4 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 frontend/angular/src/app/lobby-i18n.spec.ts diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 48a1a9b..f9d8c72 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; -import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; +import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -64,7 +64,7 @@ type LeaderboardResponse = FinishGameResponse;
`, }) -export class HostShellComponent implements OnInit { +export class HostShellComponent implements OnInit, OnDestroy { locale = resolvePreferredLocale(); readonly clientHasNoAudioOutput = clientHasNoAudioOutput; @@ -84,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; } @@ -105,6 +109,11 @@ export class HostShellComponent implements OnInit { void this.refreshSession(); } + ngOnDestroy(): void { + this.unsubscribeLocale?.(); + this.unsubscribeLocale = null; + } + copy(key: string): string { return t(key, this.locale); } 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 d768eab..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,7 +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, t } from '../../lobby-i18n'; +import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -112,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) { @@ -125,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] ?? ''; @@ -151,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.clearReconnectTimer(); this.clearStateSyncTimer(); + this.unsubscribeLocale?.(); + this.unsubscribeLocale = null; } private readonly handleOnline = (): void => { 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 index 56b8d50..1637560 100644 --- a/frontend/angular/src/app/lobby-i18n.ts +++ b/frontend/angular/src/app/lobby-i18n.ts @@ -5,6 +5,9 @@ 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)) { @@ -20,25 +23,45 @@ export function normalizeLocale(rawLocale?: string | null): SupportedLocale { } export function resolvePreferredLocale(): SupportedLocale { + if (activeLocale) { + return activeLocale; + } + if (typeof window === 'undefined') { - return DEFAULT_LOCALE; + 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 : ''; - return normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); + 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; -- 2.39.5