client: missions screen — narrative, chips, chance bar, loot, abandon

Available missions now show description, type chip (colored by type),
success probability bar (green/gold/red), minLevel lock with 🔒.
Reward loot item displayed in footer. Active missions show reward
loot and an Abandon button (confirm dialog, −1 rep warning).
Result modal shows loot and fine details.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:12:03 +02:00
parent bf5cfdb4ed
commit bdee84244f
+115 -47
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/api'; import { api } from '../api/api';
import { ApiError } from '../api/http'; import { ApiError } from '../api/http';
import type { ActiveMission, ClaimMissionResponse, Mission } from '../api/types'; import type { ActiveMission, AvailableMission, ClaimMissionResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Countdown } from '../components/Countdown'; import { Countdown } from '../components/Countdown';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
@@ -13,10 +13,17 @@ import {
MISSION_TYPE_LABELS, MISSION_TYPE_LABELS,
} from '../shared/format'; } from '../shared/format';
/** Colore della barra probabilità: verde → oro → rosso. */
function chanceColor(chance: number): string {
if (chance >= 0.7) return '#4caf6e';
if (chance >= 0.45) return '#d4a017';
return '#d9534f';
}
export function MissionsScreen() { export function MissionsScreen() {
const { setPlayer } = useAuth(); const { setPlayer } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [available, setAvailable] = useState<Mission[]>([]); const [available, setAvailable] = useState<AvailableMission[]>([]);
const [active, setActive] = useState<ActiveMission[]>([]); const [active, setActive] = useState<ActiveMission[]>([]);
const [result, setResult] = useState<ClaimMissionResponse | null>(null); const [result, setResult] = useState<ClaimMissionResponse | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -33,7 +40,7 @@ export function MissionsScreen() {
void reload(); void reload();
}, [reload]); }, [reload]);
async function start(mission: Mission) { async function start(mission: AvailableMission) {
setBusy(true); setBusy(true);
try { try {
await api.startMission(mission.id); await api.startMission(mission.id);
@@ -60,6 +67,23 @@ export function MissionsScreen() {
} }
} }
async function abandon(mission: ActiveMission) {
if (!window.confirm(`Abbandonare "${mission.mission.title}"? Perderai reputazione e la merce già consegnata.`)) {
return;
}
setBusy(true);
try {
const res = await api.abandonMission(mission.playerMissionId);
setPlayer(res.player);
toast(`Missione abbandonata (${res.reputationChange} reputazione)`, 'info');
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(); const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now();
return ( return (
@@ -71,25 +95,37 @@ export function MissionsScreen() {
<h3 className="card__title">In corso</h3> <h3 className="card__title">In corso</h3>
<ul className="list"> <ul className="list">
{active.map((m) => ( {active.map((m) => (
<li key={m.playerMissionId} className="list__row"> <li key={m.playerMissionId} className="mission">
<div> <div className="mission__head">
<strong>{m.mission.title}</strong> <strong>{m.mission.title}</strong>
<p className="muted small"> {isReady(m) ? (
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '} <button
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP type="button"
</p> className="btn btn--primary btn--small"
disabled={busy}
onClick={() => void claim(m.playerMissionId)}
>
Riscuoti
</button>
) : (
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
)}
</div> </div>
{isReady(m) ? ( <p className="muted small">
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
{m.mission.rewardItemName &&
` + ${m.mission.rewardItemQuantity}× ${m.mission.rewardItemName}`}
</p>
{!isReady(m) && (
<button <button
type="button" type="button"
className="btn btn--primary btn--small" className="btn btn--ghost btn--small mission__abandon"
disabled={busy} disabled={busy}
onClick={() => void claim(m.playerMissionId)} onClick={() => void abandon(m)}
> >
Riscuoti Abbandona
</button> </button>
) : (
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
)} )}
</li> </li>
))} ))}
@@ -103,36 +139,64 @@ export function MissionsScreen() {
<p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p> <p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p>
) : ( ) : (
<ul className="list"> <ul className="list">
{available.map((mission) => ( {available.map((mission) => {
<li key={mission.id} className="mission"> const locked = !mission.canStart;
<div className="mission__head"> return (
<strong>{mission.title}</strong> <li key={mission.id} className={`mission${locked ? ' mission--locked' : ''}`}>
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span> <div className="mission__head">
</div> <strong>{mission.title}</strong>
<p className="muted small"> <span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
{MISSION_TYPE_LABELS[mission.type]} · durata{' '} </div>
{formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)} <span className={`chip chip--${mission.type.toLowerCase()}`}>
</p> {MISSION_TYPE_LABELS[mission.type]}
{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> </span>
<button <p className="mission__description">{mission.description}</p>
type="button" <p className="muted small">
className="btn btn--secondary btn--small" durata {formatDuration(mission.durationSeconds)} · rischio{' '}
disabled={busy} {formatPercent(mission.risk)}
onClick={() => void start(mission)} </p>
>
Avvia <div className="chancebar" title="Probabilità di successo stimata">
</button> <div
</div> className="chancebar__fill"
</li> style={{
))} width: `${mission.estimatedSuccessChance * 100}%`,
background: chanceColor(mission.estimatedSuccessChance),
}}
/>
<span className="chancebar__text">
successo {formatPercent(mission.estimatedSuccessChance)}
</span>
</div>
{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
{mission.rewardItemName &&
` · 🎁 ${mission.rewardItemQuantity}× ${mission.rewardItemName}`}
</span>
{locked ? (
<span className="mission__locked">🔒 liv. {mission.minLevel}</span>
) : (
<button
type="button"
className="btn btn--secondary btn--small"
disabled={busy}
onClick={() => void start(mission)}
>
Avvia
</button>
)}
</div>
</li>
);
})}
</ul> </ul>
)} )}
</section> </section>
@@ -147,14 +211,18 @@ export function MissionsScreen() {
<li>+{formatMoney(result.rewardMoney)}</li> <li>+{formatMoney(result.rewardMoney)}</li>
<li>+{result.rewardXp} XP</li> <li>+{result.rewardXp} XP</li>
<li>+{result.reputationChange} reputazione</li> <li>+{result.reputationChange} reputazione</li>
{result.levelsGained > 0 && ( {result.lootItemName && (
<li className="levelup"> <li>
Sei salito al livello {result.player.level}! 🎁 +{result.lootItemQuantity}× {result.lootItemName}
</li> </li>
)} )}
{result.levelsGained > 0 && (
<li className="levelup">Sei salito al livello {result.player.level}!</li>
)}
</ul> </ul>
) : ( ) : (
<ul className="result-list"> <ul className="result-list">
{result.fine > 0 && <li>💸 multa: {formatMoney(result.fine)}</li>}
<li>{result.reputationChange} reputazione</li> <li>{result.reputationChange} reputazione</li>
<li className="muted small"> <li className="muted small">
Probabilità di successo: {formatPercent(result.successChance)} Probabilità di successo: {formatPercent(result.successChance)}