feat(spa): add baseline API client for health and session read

This commit is contained in:
2026-03-01 11:01:50 +00:00
parent 825f8c599b
commit 2e25d32ba1
7 changed files with 1607 additions and 0 deletions

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Frontend API client baseline
Dette er baseline-klientlaget for SPA-sporet.
## Kører checks lokalt
```bash
cd frontend
npm install
npm test
npm run build
```

1454
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
frontend/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "wpp-frontend-api-client-baseline",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run",
"build": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,56 @@
import type { ApiResult, HealthResponse, SessionDetailResponse } from './types';
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
async function request<T>(path: string): Promise<ApiResult<T>> {
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method: 'GET',
headers: { Accept: 'application/json' }
});
} catch {
return {
ok: false,
status: 0,
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
};
}
let payload: unknown;
try {
payload = await response.json();
} catch {
return {
ok: false,
status: response.status,
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error: {
kind: 'http',
status: response.status,
message: `HTTP ${response.status}`,
payload
}
};
}
return { ok: true, status: response.status, data: payload as T };
}
return {
health: () => request<HealthResponse>('/healthz'),
getSession: (code: string) =>
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`)
};
}

57
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,57 @@
export interface HealthResponse {
ok: boolean;
service: string;
}
export interface SessionSummary {
code: string;
status: string;
host_id: number | null;
current_round: number;
players_count: number;
}
export interface SessionPlayer {
id: number;
nickname: string;
score: number;
is_connected: boolean;
}
export interface SessionAnswer {
text: string;
}
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
phase: string;
available_actions: string[];
constraints: Record<string, unknown>;
}
export interface SessionDetailResponse {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
phase_view_model: PhaseViewModel;
}
export type ApiErrorKind = 'network' | 'http' | 'parse';
export interface ApiFailure {
kind: ApiErrorKind;
message: string;
status: number;
payload?: unknown;
}
export type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: ApiFailure };

12
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"]
},
"include": ["src/api", "tests"]
}