client: auth context and API layer
Add JWT-based AuthContext (login/logout, token persistence), typed HTTP client with auth header injection, REST API functions for all game endpoints, and shared TypeScript types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
import { request } from './http';
|
||||||
|
import type {
|
||||||
|
ActiveMission,
|
||||||
|
AuthResponse,
|
||||||
|
City,
|
||||||
|
ClaimMissionResponse,
|
||||||
|
InventoryResponse,
|
||||||
|
LeaderboardResponse,
|
||||||
|
MarketResponse,
|
||||||
|
Mission,
|
||||||
|
PlayerMeResponse,
|
||||||
|
StartMissionResponse,
|
||||||
|
TradeResponse,
|
||||||
|
TravelResponse,
|
||||||
|
WorldEvent,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
register: (email: string, password: string, displayName: string) =>
|
||||||
|
request<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password, displayName },
|
||||||
|
}),
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
request<AuthResponse>('/auth/login', { method: 'POST', body: { email, password } }),
|
||||||
|
|
||||||
|
// Player
|
||||||
|
playerMe: () => request<PlayerMeResponse>('/player/me'),
|
||||||
|
travel: (cityId: string) =>
|
||||||
|
request<TravelResponse>('/player/travel', { method: 'POST', body: { cityId } }),
|
||||||
|
|
||||||
|
// Cities
|
||||||
|
cities: () => request<{ cities: City[] }>('/cities'),
|
||||||
|
|
||||||
|
// Market
|
||||||
|
market: () => request<MarketResponse>('/market/current'),
|
||||||
|
buy: (itemId: string, quantity: number) =>
|
||||||
|
request<TradeResponse>('/market/buy', { method: 'POST', body: { itemId, quantity } }),
|
||||||
|
sell: (itemId: string, quantity: number) =>
|
||||||
|
request<TradeResponse>('/market/sell', { method: 'POST', body: { itemId, quantity } }),
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
inventory: () => request<InventoryResponse>('/inventory'),
|
||||||
|
|
||||||
|
// Missions
|
||||||
|
missionsAvailable: () => request<{ missions: Mission[] }>('/missions/available'),
|
||||||
|
missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'),
|
||||||
|
startMission: (missionId: string) =>
|
||||||
|
request<StartMissionResponse>(`/missions/${missionId}/start`, { method: 'POST' }),
|
||||||
|
claimMission: (playerMissionId: string) =>
|
||||||
|
request<ClaimMissionResponse>(`/missions/${playerMissionId}/claim`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Events
|
||||||
|
events: () => request<{ events: WorldEvent[] }>('/events/current'),
|
||||||
|
|
||||||
|
// Leaderboard
|
||||||
|
leaderboard: (metric: 'money' | 'reputation', limit = 20) =>
|
||||||
|
request<LeaderboardResponse>(`/leaderboard/${metric}?limit=${limit}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
/** Errore restituito dal server nella forma { error: { code, message } }. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken: string | null = null;
|
||||||
|
|
||||||
|
export function setAccessToken(token: string | null): void {
|
||||||
|
accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_URL}/api/v1${path}`, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, 'NETWORK_ERROR', 'Server non raggiungibile');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let code = 'REQUEST_ERROR';
|
||||||
|
let message = `Errore ${response.status}`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
code = payload.error?.code ?? code;
|
||||||
|
message = payload.error?.message ?? message;
|
||||||
|
} catch {
|
||||||
|
// risposta non JSON: si tengono i default
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/** DTO speculari alle risposte del server (server/src/modules/*). */
|
||||||
|
|
||||||
|
export type Player = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
money: number;
|
||||||
|
reputation: number;
|
||||||
|
level: number;
|
||||||
|
experience: number;
|
||||||
|
currentCityId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthResponse = {
|
||||||
|
accessToken: string;
|
||||||
|
player: Player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerMeResponse = {
|
||||||
|
player: Player;
|
||||||
|
currentCity: { id: string; name: string; riskLevel: number };
|
||||||
|
xpForNextLevel: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type City = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
riskLevel: number;
|
||||||
|
policePressure: number;
|
||||||
|
economyModifier: number;
|
||||||
|
travelCost: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TravelResponse = {
|
||||||
|
player: Player;
|
||||||
|
cityName: string;
|
||||||
|
cost: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorldEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: 'MARKET_BOOM' | 'POLICE_RAID' | 'SHORTAGE' | 'BLACKOUT';
|
||||||
|
cityId?: string | null;
|
||||||
|
city?: { id: string; name: string } | null;
|
||||||
|
priceModifier: number;
|
||||||
|
policeModifier: number;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketEntry = {
|
||||||
|
itemId: string;
|
||||||
|
name: string;
|
||||||
|
buyPrice: number;
|
||||||
|
sellPrice: number;
|
||||||
|
demand: number;
|
||||||
|
supply: number;
|
||||||
|
rarity: number;
|
||||||
|
illegalLevel: number;
|
||||||
|
weight: number;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketResponse = {
|
||||||
|
city: { id: string; name: string; riskLevel: number; economyModifier: number };
|
||||||
|
prices: MarketEntry[];
|
||||||
|
activeEvents: WorldEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TradeResponse = {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
total: number;
|
||||||
|
playerMoney: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryEntry = {
|
||||||
|
itemId: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
basePrice: number;
|
||||||
|
rarity: number;
|
||||||
|
illegalLevel: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryResponse = {
|
||||||
|
items: InventoryEntry[];
|
||||||
|
totalWeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Mission = {
|
||||||
|
id: string;
|
||||||
|
cityId: string;
|
||||||
|
title: string;
|
||||||
|
type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL';
|
||||||
|
difficulty: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
rewardMoney: number;
|
||||||
|
rewardXp: number;
|
||||||
|
risk: number;
|
||||||
|
requiredItemId: string | null;
|
||||||
|
requiredItemName: string | null;
|
||||||
|
requiredItemQuantity: number | null;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActiveMission = {
|
||||||
|
playerMissionId: string;
|
||||||
|
status: 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||||
|
startedAt: string;
|
||||||
|
completesAt: string;
|
||||||
|
mission: Mission;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StartMissionResponse = {
|
||||||
|
playerMissionId: string;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
completesAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaimMissionResponse = {
|
||||||
|
playerMissionId: string;
|
||||||
|
missionTitle: string;
|
||||||
|
success: boolean;
|
||||||
|
successChance: number;
|
||||||
|
rewardMoney: number;
|
||||||
|
rewardXp: number;
|
||||||
|
reputationChange: number;
|
||||||
|
levelsGained: number;
|
||||||
|
player: Player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeaderboardEntry = {
|
||||||
|
rank: number;
|
||||||
|
playerId: string;
|
||||||
|
displayName: string;
|
||||||
|
level: number;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeaderboardResponse = {
|
||||||
|
metric: 'money' | 'reputation';
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
import { setAccessToken } from '../api/http';
|
||||||
|
import type { Player } from '../api/types';
|
||||||
|
import { connectSocket, disconnectSocket } from '../realtime/socket';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'contrabbandieri.auth';
|
||||||
|
|
||||||
|
type AuthState = { token: string; player: Player } | null;
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
token: string | null;
|
||||||
|
player: Player | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, displayName: string) => Promise<void>;
|
||||||
|
/** Aggiorna i dati del giocatore dopo un'azione (acquisto, viaggio, claim...). */
|
||||||
|
setPlayer: (player: Player) => void;
|
||||||
|
/** Ricarica il profilo dal server. */
|
||||||
|
refreshPlayer: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
function loadStoredAuth(): AuthState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as AuthState) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [auth, setAuth] = useState<AuthState>(() => {
|
||||||
|
const stored = loadStoredAuth();
|
||||||
|
// il token va impostato prima del primo render per le fetch iniziali
|
||||||
|
setAccessToken(stored?.token ?? null);
|
||||||
|
return stored;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAccessToken(auth?.token ?? null);
|
||||||
|
if (auth) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||||
|
connectSocket(auth.token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
disconnectSocket();
|
||||||
|
}
|
||||||
|
return () => disconnectSocket();
|
||||||
|
}, [auth?.token]);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const res = await api.login(email, password);
|
||||||
|
setAuth({ token: res.accessToken, player: res.player });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (email: string, password: string, displayName: string) => {
|
||||||
|
const res = await api.register(email, password, displayName);
|
||||||
|
setAuth({ token: res.accessToken, player: res.player });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPlayer = useCallback((player: Player) => {
|
||||||
|
setAuth((prev) => (prev ? { ...prev, player } : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPlayer = useCallback(async () => {
|
||||||
|
const res = await api.playerMe();
|
||||||
|
setPlayer(res.player);
|
||||||
|
}, [setPlayer]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => setAuth(null), []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
token: auth?.token ?? null,
|
||||||
|
player: auth?.player ?? null,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
setPlayer,
|
||||||
|
refreshPlayer,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[auth, login, register, setPlayer, refreshPlayer, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth deve essere usato dentro AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const euro = new Intl.NumberFormat('it-IT', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatMoney(value: number): string {
|
||||||
|
return euro.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number): string {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stelle di difficoltà (1-5). */
|
||||||
|
export function difficultyStars(difficulty: number): string {
|
||||||
|
return '★'.repeat(difficulty) + '☆'.repeat(Math.max(0, 5 - difficulty));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (minutes === 0) return `${seconds}s`;
|
||||||
|
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MISSION_TYPE_LABELS: Record<string, string> = {
|
||||||
|
DELIVERY: 'Consegna',
|
||||||
|
THEFT: 'Furto',
|
||||||
|
SMUGGLING: 'Contrabbando',
|
||||||
|
INTEL: 'Informazioni',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
MARKET_BOOM: 'Boom di mercato',
|
||||||
|
POLICE_RAID: 'Retata',
|
||||||
|
SHORTAGE: 'Carenza merci',
|
||||||
|
BLACKOUT: 'Blackout',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Etichetta del rischio città (1-5). */
|
||||||
|
export function riskLabel(riskLevel: number): string {
|
||||||
|
if (riskLevel <= 2) return 'basso';
|
||||||
|
if (riskLevel === 3) return 'medio';
|
||||||
|
return 'alto';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user