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