From 0f91ace43e1686c7211d59a41f37591a2e4d83eb Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 9 Jun 2026 23:42:35 +0200 Subject: [PATCH] client: screens and app routing Add all game screens: Login, Dashboard, Market (buy/sell), Inventory, Missions, Travel (city selection), Leaderboard; wired via react-router in App.tsx with protected routes. Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.tsx | 36 +++++ client/src/screens/DashboardScreen.tsx | 89 ++++++++++++ client/src/screens/InventoryScreen.tsx | 50 +++++++ client/src/screens/LeaderboardScreen.tsx | 66 +++++++++ client/src/screens/LoginScreen.tsx | 98 +++++++++++++ client/src/screens/MarketScreen.tsx | 156 ++++++++++++++++++++ client/src/screens/MissionsScreen.tsx | 172 +++++++++++++++++++++++ client/src/screens/TravelScreen.tsx | 69 +++++++++ 8 files changed, 736 insertions(+) create mode 100644 client/src/App.tsx create mode 100644 client/src/screens/DashboardScreen.tsx create mode 100644 client/src/screens/InventoryScreen.tsx create mode 100644 client/src/screens/LeaderboardScreen.tsx create mode 100644 client/src/screens/LoginScreen.tsx create mode 100644 client/src/screens/MarketScreen.tsx create mode 100644 client/src/screens/MissionsScreen.tsx create mode 100644 client/src/screens/TravelScreen.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..30d9793 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,36 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from './auth/AuthContext'; +import { Layout } from './components/Layout'; +import { DashboardScreen } from './screens/DashboardScreen'; +import { InventoryScreen } from './screens/InventoryScreen'; +import { LeaderboardScreen } from './screens/LeaderboardScreen'; +import { LoginScreen } from './screens/LoginScreen'; +import { MarketScreen } from './screens/MarketScreen'; +import { MissionsScreen } from './screens/MissionsScreen'; +import { TravelScreen } from './screens/TravelScreen'; + +export function App() { + const { token } = useAuth(); + + if (!token) { + return ( + + } /> + + ); + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/client/src/screens/DashboardScreen.tsx b/client/src/screens/DashboardScreen.tsx new file mode 100644 index 0000000..ddceaaf --- /dev/null +++ b/client/src/screens/DashboardScreen.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api/api'; +import type { PlayerMeResponse, WorldEvent } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { EVENT_TYPE_LABELS, formatMoney, riskLabel } from '../shared/format'; + +export function DashboardScreen() { + const { player, setPlayer, logout } = useAuth(); + const [me, setMe] = useState(null); + const [events, setEvents] = useState([]); + + useEffect(() => { + void api.playerMe().then((res) => { + setMe(res); + setPlayer(res.player); + }); + void api.events().then((res) => setEvents(res.events)); + }, [setPlayer]); + + if (!player) return null; + + const xpNext = me?.xpForNextLevel ?? null; + const xpRatio = xpNext ? Math.min(1, player.experience / xpNext) : 0; + + return ( +
+
+
+

{player.displayName}

+ liv. {player.level} +
+ {me && ( +

+ 📍 {me.currentCity.name} · rischio {riskLabel(me.currentCity.riskLevel)} +

+ )} +
+
+ Denaro + {formatMoney(player.money)} +
+
+ Reputazione + {player.reputation} +
+
+ {xpNext !== null && ( +
+
+ + {player.experience}/{xpNext} XP + +
+ )} +
+ +
+

Eventi in corso

+ {events.length === 0 ? ( +

Tutto tranquillo... per ora.

+ ) : ( +
    + {events.map((event) => ( +
  • +
    + {event.title} + + {' '} + · {event.city?.name ?? 'tutte le città'} ·{' '} + {EVENT_TYPE_LABELS[event.type] ?? event.type} + +

    {event.description}

    +
    +
  • + ))} +
+ )} +
+ + + 🏆 Classifiche + + +
+ ); +} diff --git a/client/src/screens/InventoryScreen.tsx b/client/src/screens/InventoryScreen.tsx new file mode 100644 index 0000000..bcbd54f --- /dev/null +++ b/client/src/screens/InventoryScreen.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api/api'; +import type { InventoryResponse } from '../api/types'; +import { formatMoney } from '../shared/format'; + +export function InventoryScreen() { + const [inventory, setInventory] = useState(null); + + useEffect(() => { + void api.inventory().then(setInventory); + }, []); + + if (!inventory) return

Caricamento zaino...

; + + return ( +
+

Zaino

+ + {inventory.items.length === 0 ? ( +
+

Lo zaino è vuoto.

+ + Vai al mercato + +
+ ) : ( + <> +
    + {inventory.items.map((item) => ( +
  • +
    + {item.name} +

    + valore base {formatMoney(item.basePrice)} · peso {item.weight}/unità +

    +
    + ×{item.quantity} +
  • + ))} +
+

Peso totale: {inventory.totalWeight}

+ + Vendi al mercato + + + )} +
+ ); +} diff --git a/client/src/screens/LeaderboardScreen.tsx b/client/src/screens/LeaderboardScreen.tsx new file mode 100644 index 0000000..a4d0eee --- /dev/null +++ b/client/src/screens/LeaderboardScreen.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/api'; +import type { LeaderboardResponse } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { formatMoney } from '../shared/format'; + +type Metric = 'money' | 'reputation'; + +export function LeaderboardScreen() { + const { player } = useAuth(); + const [metric, setMetric] = useState('money'); + const [board, setBoard] = useState(null); + + useEffect(() => { + setBoard(null); + void api.leaderboard(metric).then(setBoard); + }, [metric]); + + return ( +
+

Classifiche

+ +
+ + +
+ + {!board ? ( +

Caricamento...

+ ) : board.entries.length === 0 ? ( +

Classifica vuota.

+ ) : ( +
    + {board.entries.map((entry) => ( +
  • +
    + #{entry.rank} + {entry.displayName} + · liv. {entry.level} +
    + + {metric === 'money' ? formatMoney(entry.value) : entry.value} + +
  • + ))} +
+ )} +

Le classifiche si aggiornano ogni 15 minuti.

+
+ ); +} diff --git a/client/src/screens/LoginScreen.tsx b/client/src/screens/LoginScreen.tsx new file mode 100644 index 0000000..8a96d10 --- /dev/null +++ b/client/src/screens/LoginScreen.tsx @@ -0,0 +1,98 @@ +import { useState, type FormEvent } from 'react'; +import { ApiError } from '../api/http'; +import { useAuth } from '../auth/AuthContext'; + +export function LoginScreen() { + const { login, register } = useAuth(); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + if (mode === 'login') { + await login(email, password); + } else { + await register(email, password, displayName); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Errore imprevisto'); + } finally { + setBusy(false); + } + } + + return ( +
+

CONTRABBANDIERI

+

La città non dorme. Nemmeno tu.

+ +
+ + +
+ +
+ {mode === 'register' && ( + + )} + + + + {error &&

{error}

} + + +
+
+ ); +} diff --git a/client/src/screens/MarketScreen.tsx b/client/src/screens/MarketScreen.tsx new file mode 100644 index 0000000..90488c1 --- /dev/null +++ b/client/src/screens/MarketScreen.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../api/api'; +import { ApiError } from '../api/http'; +import type { InventoryResponse, MarketEntry, MarketResponse } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { useToast } from '../components/Toast'; +import { getSocket } from '../realtime/socket'; +import { formatMoney } from '../shared/format'; + +export function MarketScreen() { + const { player, refreshPlayer } = useAuth(); + const { toast } = useToast(); + const [market, setMarket] = useState(null); + const [inventory, setInventory] = useState(null); + const [selected, setSelected] = useState(null); + const [quantity, setQuantity] = useState(1); + const [busy, setBusy] = useState(false); + + const reload = useCallback(async () => { + const [m, inv] = await Promise.all([api.market(), api.inventory()]); + setMarket(m); + setInventory(inv); + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + // Il mercato si aggiorna a ogni tick del server + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + const onUpdate = () => void reload(); + socket.on('market:updated', onUpdate); + return () => { + socket.off('market:updated', onUpdate); + }; + }, [reload]); + + const owned = (itemId: string) => + inventory?.items.find((i) => i.itemId === itemId)?.quantity ?? 0; + + async function trade(kind: 'buy' | 'sell') { + if (!selected) return; + setBusy(true); + try { + const fn = kind === 'buy' ? api.buy : api.sell; + const res = await fn(selected.itemId, quantity); + toast( + kind === 'buy' + ? `Comprati ${res.quantity}× ${res.itemName} per ${formatMoney(res.total)}` + : `Venduti ${res.quantity}× ${res.itemName} per ${formatMoney(res.total)}`, + 'success', + ); + setSelected(null); + setQuantity(1); + await Promise.all([reload(), refreshPlayer()]); + } catch (err) { + toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); + } finally { + setBusy(false); + } + } + + if (!market) return

Caricamento mercato...

; + + return ( +
+

Mercato di {market.city.name}

+ + {market.activeEvents.length > 0 && ( +
+ {market.activeEvents.map((e) => ( + ⚡ {e.title} + ))} +
+ )} + +
    + {market.prices.map((entry) => ( +
  • { + setSelected(entry); + setQuantity(1); + }} + > +
    + {entry.name} + {owned(entry.itemId) > 0 && ( + ×{owned(entry.itemId)} + )} +
    +
    + {formatMoney(entry.buyPrice)} + {formatMoney(entry.sellPrice)} +
    +
  • + ))} +
+

+ Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto. +

+ + {selected && ( +
setSelected(null)}> +
e.stopPropagation()}> +

{selected.name}

+

+ Possiedi: {owned(selected.itemId)} · peso {selected.weight}/unità +

+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/client/src/screens/MissionsScreen.tsx b/client/src/screens/MissionsScreen.tsx new file mode 100644 index 0000000..cf3836e --- /dev/null +++ b/client/src/screens/MissionsScreen.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../api/api'; +import { ApiError } from '../api/http'; +import type { ActiveMission, ClaimMissionResponse, Mission } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { Countdown } from '../components/Countdown'; +import { useToast } from '../components/Toast'; +import { + difficultyStars, + formatDuration, + formatMoney, + formatPercent, + MISSION_TYPE_LABELS, +} from '../shared/format'; + +export function MissionsScreen() { + const { setPlayer } = useAuth(); + const { toast } = useToast(); + const [available, setAvailable] = useState([]); + const [active, setActive] = useState([]); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + // tick fittizio per ri-renderizzare quando un countdown arriva a zero + const [, setTick] = useState(0); + + const reload = useCallback(async () => { + const [av, ac] = await Promise.all([api.missionsAvailable(), api.missionsActive()]); + setAvailable(av.missions); + setActive(ac.missions); + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + async function start(mission: Mission) { + setBusy(true); + try { + await api.startMission(mission.id); + toast(`Missione "${mission.title}" avviata`, 'success'); + await reload(); + } catch (err) { + toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); + } finally { + setBusy(false); + } + } + + async function claim(playerMissionId: string) { + setBusy(true); + try { + const res = await api.claimMission(playerMissionId); + setResult(res); + setPlayer(res.player); + await reload(); + } catch (err) { + toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); + } finally { + setBusy(false); + } + } + + const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now(); + + return ( +
+

Missioni

+ + {active.length > 0 && ( +
+

In corso

+
    + {active.map((m) => ( +
  • +
    + {m.mission.title} +

    + {MISSION_TYPE_LABELS[m.mission.type]} · premio{' '} + {formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP +

    +
    + {isReady(m) ? ( + + ) : ( + setTick((t) => t + 1)} /> + )} +
  • + ))} +
+
+ )} + +
+

Disponibili in città

+ {available.length === 0 ? ( +

Nessuna missione al momento. Riprova tra un minuto.

+ ) : ( +
    + {available.map((mission) => ( +
  • +
    + {mission.title} + {difficultyStars(mission.difficulty)} +
    +

    + {MISSION_TYPE_LABELS[mission.type]} · durata{' '} + {formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)} +

    + {mission.requiredItemName && ( +

    + Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName} +

    + )} +
    + + {formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP + + +
    +
  • + ))} +
+ )} +
+ + {result && ( +
setResult(null)}> +
e.stopPropagation()}> +

{result.success ? '✅ Colpo riuscito!' : '❌ Missione fallita'}

+

"{result.missionTitle}"

+ {result.success ? ( +
    +
  • +{formatMoney(result.rewardMoney)}
  • +
  • +{result.rewardXp} XP
  • +
  • +{result.reputationChange} reputazione
  • + {result.levelsGained > 0 && ( +
  • + Sei salito al livello {result.player.level}! +
  • + )} +
+ ) : ( +
    +
  • {result.reputationChange} reputazione
  • +
  • + Probabilità di successo: {formatPercent(result.successChance)} +
  • +
+ )} + +
+
+ )} +
+ ); +} diff --git a/client/src/screens/TravelScreen.tsx b/client/src/screens/TravelScreen.tsx new file mode 100644 index 0000000..c63f3b9 --- /dev/null +++ b/client/src/screens/TravelScreen.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../api/api'; +import { ApiError } from '../api/http'; +import type { City } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { useToast } from '../components/Toast'; +import { formatMoney, riskLabel } from '../shared/format'; + +export function TravelScreen() { + const { player, setPlayer } = useAuth(); + const { toast } = useToast(); + const [cities, setCities] = useState([]); + const [busy, setBusy] = useState(false); + + const reload = useCallback(async () => { + const res = await api.cities(); + setCities(res.cities); + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + async function travelTo(city: City) { + setBusy(true); + try { + const res = await api.travel(city.id); + setPlayer(res.player); + toast(`Benvenuto a ${res.cityName} (−${formatMoney(res.cost)})`, 'success'); + } catch (err) { + toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); + } finally { + setBusy(false); + } + } + + return ( +
+

Città

+
    + {cities.map((city) => { + const isCurrent = player?.currentCityId === city.id; + const canAfford = (player?.money ?? 0) >= city.travelCost; + return ( +
  • +
    + {city.name} + {isCurrent && sei qui} +

    + rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier} +

    +
    + {!isCurrent && ( + + )} +
  • + ); + })} +
+
+ ); +}