Merge pull request '[Need-to-have] #175 Shared i18n across frontend/backend for lobby flow' (#183) from dev/issue-175-shared-i18n-lobby into main
All checks were successful
CI / test-and-quality (push) Successful in 1m53s

This commit was merged in pull request #183.
This commit is contained in:
2026-03-01 16:14:32 +01:00
8 changed files with 166 additions and 26 deletions

View File

@@ -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<string, FrontendErrorKey> = {
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<string, unknown>;
const code = typeof record.error_code === 'string' ? record.error_code : '';
const mappedKey = apiErrorMap[code];
if (!mappedKey) {
return lobbyMessage(fallbackKey);
}
return lobbyMessage(mappedKey);
}

View File

@@ -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 };
}

View File

@@ -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();
});

View File

@@ -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"]
}

20
lobby/i18n.py Normal file
View File

@@ -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)

View File

@@ -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()})

View File

@@ -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"])

27
shared/i18n/lobby.json Normal file
View File

@@ -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"
}
}
}