From 3253f4d343413aa96c98b0ddea3dfb5bc9544045 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 15:07:34 +0000 Subject: [PATCH] feat(i18n): share lobby message catalog across frontend/backend --- frontend/src/spa/lobby-i18n.ts | 31 +++++++++++ frontend/src/spa/vertical-slice.ts | 11 ++-- frontend/tests/vertical-slice.test.ts | 12 ++--- frontend/tsconfig.json | 3 +- lobby/i18n.py | 20 +++++++ lobby/ui_views.py | 13 ++++- lobby/views.py | 75 ++++++++++++++++++++++----- shared/i18n/lobby.json | 27 ++++++++++ 8 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 frontend/src/spa/lobby-i18n.ts create mode 100644 lobby/i18n.py create mode 100644 shared/i18n/lobby.json diff --git a/frontend/src/spa/lobby-i18n.ts b/frontend/src/spa/lobby-i18n.ts new file mode 100644 index 0000000..30184d4 --- /dev/null +++ b/frontend/src/spa/lobby-i18n.ts @@ -0,0 +1,31 @@ +import lobbyCatalog from '../../../shared/i18n/lobby.json'; + +type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors; + +const frontendErrors = lobbyCatalog.frontend.errors; + +const apiErrorMap: Record = { + session_code_required: 'session_code_required', + session_not_found: 'session_not_found', + nickname_invalid: 'nickname_invalid', + nickname_taken: 'nickname_taken' +}; + +export function lobbyMessage(key: FrontendErrorKey): string { + return frontendErrors[key] ?? frontendErrors.unknown; +} + +export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string { + if (!payload || typeof payload !== 'object') { + return lobbyMessage(fallbackKey); + } + + const record = payload as Record; + const code = typeof record.error_code === 'string' ? record.error_code : ''; + const mappedKey = apiErrorMap[code]; + if (!mappedKey) { + return lobbyMessage(fallbackKey); + } + + return lobbyMessage(mappedKey); +} diff --git a/frontend/src/spa/vertical-slice.ts b/frontend/src/spa/vertical-slice.ts index 7a2d6d0..a6bdac4 100644 --- a/frontend/src/spa/vertical-slice.ts +++ b/frontend/src/spa/vertical-slice.ts @@ -7,6 +7,7 @@ import { type SessionContextStore as PersistedSessionContextStore } from './session-context-store'; import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine'; +import { lobbyMessage, lobbyMessageFromApiPayload } from './lobby-i18n'; export type AsyncState = 'idle' | 'loading' | 'success' | 'error'; @@ -57,7 +58,7 @@ export function createVerticalSliceController( if (!state.sessionCode) { state.loadingSession = false; - state.errorMessage = 'Session-kode mangler.'; + state.errorMessage = lobbyMessage('session_code_required'); return { ...state }; } @@ -65,7 +66,7 @@ export function createVerticalSliceController( state.loadingSession = false; if (!result.ok) { - state.errorMessage = 'Kunne ikke hente lobby-status.'; + state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed'); state.gameplayPhase = null; return { ...state }; } @@ -92,7 +93,7 @@ export function createVerticalSliceController( const join = await api.joinSession({ code: requestCode, nickname }); if (!join.ok) { state.joinState = 'error'; - state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.'; + state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed'); return { ...state }; } @@ -119,14 +120,14 @@ export function createVerticalSliceController( if (!codeToUse) { state.startRoundState = 'error'; - state.errorMessage = 'Session-kode mangler.'; + state.errorMessage = lobbyMessage('session_code_required'); return { ...state }; } const start = await api.startRound(codeToUse, { category_slug: categorySlug }); if (!start.ok) { state.startRoundState = 'error'; - state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.'; + state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed'); return { ...state }; } diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts index 07be788..81544ee 100644 --- a/frontend/tests/vertical-slice.test.ts +++ b/frontend/tests/vertical-slice.test.ts @@ -161,7 +161,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { joinSession: vi.fn().mockResolvedValue({ ok: false, status: 404, - error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } } + error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } } }) }); @@ -170,7 +170,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { const state = controller.getState(); expect(state.joinState).toBe('error'); - expect(state.errorMessage).toContain('Join fejlede'); + expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.'); }); it('surfaces a friendly error when round start fails', async () => { @@ -178,7 +178,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { startRound: vi.fn().mockResolvedValue({ ok: false, status: 400, - error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } } + error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } } }) }); @@ -187,7 +187,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { const state = controller.getState(); expect(state.startRoundState).toBe('error'); - expect(state.errorMessage).toContain('Kunne ikke starte runden'); + expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.'); }); it('shows local validation error and avoids API call when hydrating without any session code', async () => { @@ -197,7 +197,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { await controller.hydrateLobby(' '); const state = controller.getState(); - expect(state.errorMessage).toBe('Session-kode mangler.'); + expect(state.errorMessage).toBe('Session code is required.'); expect(state.loadingSession).toBe(false); expect(api.getSession).not.toHaveBeenCalled(); }); @@ -210,7 +210,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => { const state = controller.getState(); expect(state.startRoundState).toBe('error'); - expect(state.errorMessage).toBe('Session-kode mangler.'); + expect(state.errorMessage).toBe('Session code is required.'); expect(api.startRound).not.toHaveBeenCalled(); }); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 74e10ad..84641c3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -3,10 +3,11 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "resolveJsonModule": true, "strict": true, "skipLibCheck": true, "lib": ["ES2022", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src", "tests"] + "include": ["src", "tests", "../shared/i18n/*.json"] } diff --git a/lobby/i18n.py b/lobby/i18n.py new file mode 100644 index 0000000..7d2f644 --- /dev/null +++ b/lobby/i18n.py @@ -0,0 +1,20 @@ +import json +from functools import lru_cache +from pathlib import Path + +from django.http import JsonResponse + + +@lru_cache(maxsize=1) +def lobby_i18n_catalog() -> dict: + catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json" + with catalog_path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def lobby_i18n_errors() -> dict: + return lobby_i18n_catalog().get("backend", {}).get("error_codes", {}) + + +def api_error(*, code: str, message: str, status: int) -> JsonResponse: + return JsonResponse({"error": message, "error_code": code}, status=status) diff --git a/lobby/ui_views.py b/lobby/ui_views.py index af88c3f..b611708 100644 --- a/lobby/ui_views.py +++ b/lobby/ui_views.py @@ -5,6 +5,7 @@ from django.shortcuts import render from fupogfakta.models import Category from .feature_flags import use_spa_ui +from .i18n import lobby_i18n_catalog def _render_spa_shell(request, shell_route: str, shell_kind: str): @@ -15,6 +16,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str): "shell_route": shell_route, "shell_kind": shell_kind, "spa_asset_base": settings.WPP_SPA_ASSET_BASE, + "lobby_i18n": lobby_i18n_catalog(), }, ) @@ -30,11 +32,18 @@ def host_screen(request, spa_path=None): return _render_spa_shell(request, host_route, "host") categories = Category.objects.filter(is_active=True).order_by("name") - return render(request, "lobby/host_screen.html", {"categories": categories}) + return render( + request, + "lobby/host_screen.html", + { + "categories": categories, + "lobby_i18n": lobby_i18n_catalog(), + }, + ) def player_screen(request): if use_spa_ui(): return _render_spa_shell(request, "/player", "player") - return render(request, "lobby/player_screen.html") + return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()}) diff --git a/lobby/views.py b/lobby/views.py index 0a12ce6..6790270 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -20,6 +20,8 @@ from fupogfakta.models import ( ScoreEvent, ) +from .i18n import api_error, lobby_i18n_errors + SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 MAX_CODE_GENERATION_ATTEMPTS = 20 @@ -29,6 +31,7 @@ JOINABLE_STATUSES = { GameSession.Status.GUESS, GameSession.Status.REVEAL, } +ERROR_CODES = lobby_i18n_errors() def _json_body(request: HttpRequest) -> dict: @@ -124,21 +127,41 @@ def join_session(request: HttpRequest) -> JsonResponse: nickname = str(payload.get("nickname", "")).strip() if not code: - return JsonResponse({"error": "Session code is required"}, status=400) + return api_error( + code=ERROR_CODES.get("session_code_required", "session_code_required"), + message="Session code is required", + status=400, + ) if len(nickname) < 2 or len(nickname) > 40: - return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400) + return api_error( + code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"), + message="Nickname must be between 2 and 40 characters", + status=400, + ) try: session = GameSession.objects.get(code=code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + code=ERROR_CODES.get("session_not_found", "session_not_found"), + message="Session not found", + status=404, + ) if session.status not in JOINABLE_STATUSES: - return JsonResponse({"error": "Session is not joinable"}, status=400) + return api_error( + code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"), + message="Session is not joinable", + status=400, + ) if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): - return JsonResponse({"error": "Nickname already taken"}, status=409) + return api_error( + code=ERROR_CODES.get("nickname_taken", "nickname_taken"), + message="Nickname already taken", + status=409, + ) player = Player.objects.create(session=session, nickname=nickname) @@ -166,7 +189,11 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + code=ERROR_CODES.get("session_not_found", "session_not_found"), + message="Session not found", + status=404, + ) players = list( session.players.order_by("nickname").values( @@ -223,25 +250,41 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: category_slug = str(payload.get("category_slug", "")).strip() if not category_slug: - return JsonResponse({"error": "category_slug is required"}, status=400) + return api_error( + code=ERROR_CODES.get("category_slug_required", "category_slug_required"), + message="category_slug is required", + status=400, + ) session_code = _normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + code=ERROR_CODES.get("session_not_found", "session_not_found"), + message="Session not found", + status=404, + ) if session.host_id != request.user.id: return JsonResponse({"error": "Only host can start round"}, status=403) if session.status != GameSession.Status.LOBBY: - return JsonResponse({"error": "Round can only be started from lobby"}, status=400) + return api_error( + code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"), + message="Round can only be started from lobby", + status=400, + ) try: category = Category.objects.get(slug=category_slug, is_active=True) except Category.DoesNotExist: - return JsonResponse({"error": "Category not found"}, status=404) + return api_error( + code=ERROR_CODES.get("category_not_found", "category_not_found"), + message="Category not found", + status=404, + ) if not Question.objects.filter(category=category, is_active=True).exists(): return JsonResponse({"error": "Category has no active questions"}, status=400) @@ -249,7 +292,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: with transaction.atomic(): session = GameSession.objects.select_for_update().get(pk=session.pk) if session.status != GameSession.Status.LOBBY: - return JsonResponse({"error": "Round can only be started from lobby"}, status=400) + return api_error( + code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"), + message="Round can only be started from lobby", + status=400, + ) round_config, created = RoundConfig.objects.get_or_create( session=session, @@ -257,7 +304,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: defaults={"category": category}, ) if not created: - return JsonResponse({"error": "Round already configured"}, status=409) + return api_error( + code=ERROR_CODES.get("round_already_configured", "round_already_configured"), + message="Round already configured", + status=409, + ) session.status = GameSession.Status.LIE session.save(update_fields=["status"]) diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json new file mode 100644 index 0000000..630940f --- /dev/null +++ b/shared/i18n/lobby.json @@ -0,0 +1,27 @@ +{ + "frontend": { + "errors": { + "session_code_required": "Session code is required.", + "session_fetch_failed": "Could not load lobby status.", + "join_failed": "Join failed. Check code or nickname and try again.", + "start_round_failed": "Could not start round. Refresh the lobby and try again.", + "session_not_found": "Session code is invalid or the session no longer exists.", + "nickname_invalid": "Nickname must be between 2 and 40 characters.", + "nickname_taken": "Nickname is already taken.", + "unknown": "Action failed. Refresh status and try again." + } + }, + "backend": { + "error_codes": { + "session_code_required": "session_code_required", + "nickname_invalid": "nickname_invalid", + "session_not_found": "session_not_found", + "session_not_joinable": "session_not_joinable", + "nickname_taken": "nickname_taken", + "category_slug_required": "category_slug_required", + "category_not_found": "category_not_found", + "round_start_invalid_phase": "round_start_invalid_phase", + "round_already_configured": "round_already_configured" + } + } +} -- 2.39.5