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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user