diff --git a/client/src/api/api.ts b/client/src/api/api.ts new file mode 100644 index 0000000..b2922ad --- /dev/null +++ b/client/src/api/api.ts @@ -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('/auth/register', { + method: 'POST', + body: { email, password, displayName }, + }), + login: (email: string, password: string) => + request('/auth/login', { method: 'POST', body: { email, password } }), + + // Player + playerMe: () => request('/player/me'), + travel: (cityId: string) => + request('/player/travel', { method: 'POST', body: { cityId } }), + + // Cities + cities: () => request<{ cities: City[] }>('/cities'), + + // Market + market: () => request('/market/current'), + buy: (itemId: string, quantity: number) => + request('/market/buy', { method: 'POST', body: { itemId, quantity } }), + sell: (itemId: string, quantity: number) => + request('/market/sell', { method: 'POST', body: { itemId, quantity } }), + + // Inventory + inventory: () => request('/inventory'), + + // Missions + missionsAvailable: () => request<{ missions: Mission[] }>('/missions/available'), + missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'), + startMission: (missionId: string) => + request(`/missions/${missionId}/start`, { method: 'POST' }), + claimMission: (playerMissionId: string) => + request(`/missions/${playerMissionId}/claim`, { method: 'POST' }), + + // Events + events: () => request<{ events: WorldEvent[] }>('/events/current'), + + // Leaderboard + leaderboard: (metric: 'money' | 'reputation', limit = 20) => + request(`/leaderboard/${metric}?limit=${limit}`), +}; diff --git a/client/src/api/http.ts b/client/src/api/http.ts new file mode 100644 index 0000000..cd09804 --- /dev/null +++ b/client/src/api/http.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + const headers: Record = {}; + 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; +} diff --git a/client/src/api/types.ts b/client/src/api/types.ts new file mode 100644 index 0000000..88b4a3d --- /dev/null +++ b/client/src/api/types.ts @@ -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[]; +}; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx new file mode 100644 index 0000000..01d516b --- /dev/null +++ b/client/src/auth/AuthContext.tsx @@ -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; + register: (email: string, password: string, displayName: string) => Promise; + /** Aggiorna i dati del giocatore dopo un'azione (acquisto, viaggio, claim...). */ + setPlayer: (player: Player) => void; + /** Ricarica il profilo dal server. */ + refreshPlayer: () => Promise; + logout: () => void; +}; + +const AuthContext = createContext(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(() => { + 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( + () => ({ + token: auth?.token ?? null, + player: auth?.player ?? null, + login, + register, + setPlayer, + refreshPlayer, + logout, + }), + [auth, login, register, setPlayer, refreshPlayer, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth deve essere usato dentro AuthProvider'); + return ctx; +} diff --git a/client/src/shared/format.ts b/client/src/shared/format.ts new file mode 100644 index 0000000..895ae16 --- /dev/null +++ b/client/src/shared/format.ts @@ -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 = { + DELIVERY: 'Consegna', + THEFT: 'Furto', + SMUGGLING: 'Contrabbando', + INTEL: 'Informazioni', +}; + +export const EVENT_TYPE_LABELS: Record = { + 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'; +}