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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:42:35 +02:00
parent 95864ce392
commit 0f91ace43e
8 changed files with 736 additions and 0 deletions
+36
View File
@@ -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 (
<Routes>
<Route path="*" element={<LoginScreen />} />
</Routes>
);
}
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<DashboardScreen />} />
<Route path="/market" element={<MarketScreen />} />
<Route path="/missions" element={<MissionsScreen />} />
<Route path="/inventory" element={<InventoryScreen />} />
<Route path="/travel" element={<TravelScreen />} />
<Route path="/leaderboard" element={<LeaderboardScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
+89
View File
@@ -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<PlayerMeResponse | null>(null);
const [events, setEvents] = useState<WorldEvent[]>([]);
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 (
<div className="screen">
<section className="card profile">
<div className="profile__header">
<h2>{player.displayName}</h2>
<span className="badge">liv. {player.level}</span>
</div>
{me && (
<p className="profile__city">
📍 {me.currentCity.name} · rischio {riskLabel(me.currentCity.riskLevel)}
</p>
)}
<div className="profile__stats">
<div className="stat">
<span className="stat__label">Denaro</span>
<span className="stat__value stat__value--money">{formatMoney(player.money)}</span>
</div>
<div className="stat">
<span className="stat__label">Reputazione</span>
<span className="stat__value">{player.reputation}</span>
</div>
</div>
{xpNext !== null && (
<div className="xpbar" title={`${player.experience}/${xpNext} XP`}>
<div className="xpbar__fill" style={{ width: `${xpRatio * 100}%` }} />
<span className="xpbar__text">
{player.experience}/{xpNext} XP
</span>
</div>
)}
</section>
<section className="card">
<h3 className="card__title">Eventi in corso</h3>
{events.length === 0 ? (
<p className="muted">Tutto tranquillo... per ora.</p>
) : (
<ul className="list">
{events.map((event) => (
<li key={event.id} className="list__row">
<div>
<strong>{event.title}</strong>
<span className="muted">
{' '}
· {event.city?.name ?? 'tutte le città'} ·{' '}
{EVENT_TYPE_LABELS[event.type] ?? event.type}
</span>
<p className="muted small">{event.description}</p>
</div>
</li>
))}
</ul>
)}
</section>
<Link to="/leaderboard" className="btn btn--secondary">
🏆 Classifiche
</Link>
<button type="button" className="btn btn--ghost" onClick={logout}>
Esci
</button>
</div>
);
}
+50
View File
@@ -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<InventoryResponse | null>(null);
useEffect(() => {
void api.inventory().then(setInventory);
}, []);
if (!inventory) return <p className="muted screen">Caricamento zaino...</p>;
return (
<div className="screen">
<h2 className="screen__title">Zaino</h2>
{inventory.items.length === 0 ? (
<div className="card">
<p className="muted">Lo zaino è vuoto.</p>
<Link to="/market" className="btn btn--secondary">
Vai al mercato
</Link>
</div>
) : (
<>
<ul className="list">
{inventory.items.map((item) => (
<li key={item.itemId} className="list__row">
<div>
<strong>{item.name}</strong>
<p className="muted small">
valore base {formatMoney(item.basePrice)} · peso {item.weight}/unità
</p>
</div>
<span className="badge">×{item.quantity}</span>
</li>
))}
</ul>
<p className="muted small">Peso totale: {inventory.totalWeight}</p>
<Link to="/market" className="btn btn--secondary">
Vendi al mercato
</Link>
</>
)}
</div>
);
}
+66
View File
@@ -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<Metric>('money');
const [board, setBoard] = useState<LeaderboardResponse | null>(null);
useEffect(() => {
setBoard(null);
void api.leaderboard(metric).then(setBoard);
}, [metric]);
return (
<div className="screen">
<h2 className="screen__title">Classifiche</h2>
<div className="login__tabs">
<button
type="button"
className={metric === 'money' ? 'is-active' : ''}
onClick={() => setMetric('money')}
>
Denaro
</button>
<button
type="button"
className={metric === 'reputation' ? 'is-active' : ''}
onClick={() => setMetric('reputation')}
>
Reputazione
</button>
</div>
{!board ? (
<p className="muted">Caricamento...</p>
) : board.entries.length === 0 ? (
<p className="muted">Classifica vuota.</p>
) : (
<ul className="list">
{board.entries.map((entry) => (
<li
key={entry.playerId}
className={`list__row${entry.playerId === player?.id ? ' is-current' : ''}`}
>
<div>
<span className="rank">#{entry.rank}</span>
<strong>{entry.displayName}</strong>
<span className="muted small"> · liv. {entry.level}</span>
</div>
<span className="stat__value--money">
{metric === 'money' ? formatMoney(entry.value) : entry.value}
</span>
</li>
))}
</ul>
)}
<p className="muted small">Le classifiche si aggiornano ogni 15 minuti.</p>
</div>
);
}
+98
View File
@@ -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<string | null>(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 (
<div className="login">
<h1 className="login__title">CONTRABBANDIERI</h1>
<p className="login__subtitle">La città non dorme. Nemmeno tu.</p>
<div className="login__tabs">
<button
type="button"
className={mode === 'login' ? 'is-active' : ''}
onClick={() => setMode('login')}
>
Accedi
</button>
<button
type="button"
className={mode === 'register' ? 'is-active' : ''}
onClick={() => setMode('register')}
>
Registrati
</button>
</div>
<form className="login__form" onSubmit={onSubmit}>
{mode === 'register' && (
<label>
Nome in codice
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="ShadowFox"
minLength={3}
maxLength={20}
required
/>
</label>
)}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@esempio.com"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === 'register' ? 'minimo 8 caratteri' : ''}
minLength={mode === 'register' ? 8 : 1}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn btn--primary" disabled={busy}>
{busy ? '...' : mode === 'login' ? 'Entra in città' : 'Inizia la carriera'}
</button>
</form>
</div>
);
}
+156
View File
@@ -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<MarketResponse | null>(null);
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
const [selected, setSelected] = useState<MarketEntry | null>(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 <p className="muted screen">Caricamento mercato...</p>;
return (
<div className="screen">
<h2 className="screen__title">Mercato di {market.city.name}</h2>
{market.activeEvents.length > 0 && (
<div className="banner">
{market.activeEvents.map((e) => (
<span key={e.id}> {e.title}</span>
))}
</div>
)}
<ul className="list">
{market.prices.map((entry) => (
<li
key={entry.itemId}
className="list__row list__row--clickable"
onClick={() => {
setSelected(entry);
setQuantity(1);
}}
>
<div>
<strong>{entry.name}</strong>
{owned(entry.itemId) > 0 && (
<span className="badge badge--small">×{owned(entry.itemId)}</span>
)}
</div>
<div className="prices">
<span className="price price--buy">{formatMoney(entry.buyPrice)}</span>
<span className="price price--sell">{formatMoney(entry.sellPrice)}</span>
</div>
</li>
))}
</ul>
<p className="muted small">
Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto.
</p>
{selected && (
<div className="modal-backdrop" onClick={() => setSelected(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{selected.name}</h3>
<p className="muted small">
Possiedi: {owned(selected.itemId)} · peso {selected.weight}/unità
</p>
<label className="quantity">
Quantità
<div className="quantity__controls">
<button type="button" onClick={() => setQuantity((q) => Math.max(1, q - 1))}>
</button>
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={(e) =>
setQuantity(Math.max(1, Math.min(100, Number(e.target.value) || 1)))
}
/>
<button type="button" onClick={() => setQuantity((q) => Math.min(100, q + 1))}>
+
</button>
</div>
</label>
<div className="modal__actions">
<button
type="button"
className="btn btn--primary"
disabled={busy || (player?.money ?? 0) < selected.buyPrice * quantity}
onClick={() => void trade('buy')}
>
Compra · {formatMoney(selected.buyPrice * quantity)}
</button>
<button
type="button"
className="btn btn--secondary"
disabled={busy || owned(selected.itemId) < quantity}
onClick={() => void trade('sell')}
>
Vendi · {formatMoney(selected.sellPrice * quantity)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+172
View File
@@ -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<Mission[]>([]);
const [active, setActive] = useState<ActiveMission[]>([]);
const [result, setResult] = useState<ClaimMissionResponse | null>(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 (
<div className="screen">
<h2 className="screen__title">Missioni</h2>
{active.length > 0 && (
<section className="card">
<h3 className="card__title">In corso</h3>
<ul className="list">
{active.map((m) => (
<li key={m.playerMissionId} className="list__row">
<div>
<strong>{m.mission.title}</strong>
<p className="muted small">
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
</p>
</div>
{isReady(m) ? (
<button
type="button"
className="btn btn--primary btn--small"
disabled={busy}
onClick={() => void claim(m.playerMissionId)}
>
Riscuoti
</button>
) : (
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
)}
</li>
))}
</ul>
</section>
)}
<section className="card">
<h3 className="card__title">Disponibili in città</h3>
{available.length === 0 ? (
<p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p>
) : (
<ul className="list">
{available.map((mission) => (
<li key={mission.id} className="mission">
<div className="mission__head">
<strong>{mission.title}</strong>
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
</div>
<p className="muted small">
{MISSION_TYPE_LABELS[mission.type]} · durata{' '}
{formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)}
</p>
{mission.requiredItemName && (
<p className="mission__required">
Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName}
</p>
)}
<div className="mission__footer">
<span className="mission__reward">
{formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP
</span>
<button
type="button"
className="btn btn--secondary btn--small"
disabled={busy}
onClick={() => void start(mission)}
>
Avvia
</button>
</div>
</li>
))}
</ul>
)}
</section>
{result && (
<div className="modal-backdrop" onClick={() => setResult(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{result.success ? '✅ Colpo riuscito!' : '❌ Missione fallita'}</h3>
<p className="muted">"{result.missionTitle}"</p>
{result.success ? (
<ul className="result-list">
<li>+{formatMoney(result.rewardMoney)}</li>
<li>+{result.rewardXp} XP</li>
<li>+{result.reputationChange} reputazione</li>
{result.levelsGained > 0 && (
<li className="levelup">
Sei salito al livello {result.player.level}!
</li>
)}
</ul>
) : (
<ul className="result-list">
<li>{result.reputationChange} reputazione</li>
<li className="muted small">
Probabilità di successo: {formatPercent(result.successChance)}
</li>
</ul>
)}
<button type="button" className="btn btn--primary" onClick={() => setResult(null)}>
Continua
</button>
</div>
</div>
)}
</div>
);
}
+69
View File
@@ -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<City[]>([]);
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 (
<div className="screen">
<h2 className="screen__title">Città</h2>
<ul className="list">
{cities.map((city) => {
const isCurrent = player?.currentCityId === city.id;
const canAfford = (player?.money ?? 0) >= city.travelCost;
return (
<li key={city.id} className={`list__row${isCurrent ? ' is-current' : ''}`}>
<div>
<strong>{city.name}</strong>
{isCurrent && <span className="badge badge--small">sei qui</span>}
<p className="muted small">
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
</p>
</div>
{!isCurrent && (
<button
type="button"
className="btn btn--secondary btn--small"
disabled={busy || !canAfford}
onClick={() => void travelTo(city)}
>
Viaggia · {formatMoney(city.travelCost)}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}