feat(i18n): share lobby message catalog across frontend/backend
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m8s
CI / test-and-quality (push) Successful in 2m15s

This commit is contained in:
2026-03-01 15:07:34 +00:00
committed by Asger Geel Weirsoee
parent a5c9e4f255
commit 3253f4d343
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"]
}