Files
gioco-mobile/client/src/screens/TravelScreen.tsx
T
davideandClaude Sonnet 4.6 bf5cfdb4ed client: travel screen — SVG Italy map with clickable city markers
Replace plain list with a styled SVG Italy outline (mainland, Sicily,
Sardinia). Cities rendered as markers: color by risk level, pulse
animation for current city,  icon for active events. Clicking a
marker or a list row opens a travel modal with city details and
confirm button. List remains as compact fallback below the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:53 +02:00

168 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/api';
import { ApiError } from '../api/http';
import type { City, WorldEvent } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { formatMoney, riskLabel } from '../shared/format';
/** Sagoma stilizzata dell'Italia (coordinate 0-100, stesse di City.mapX/mapY). */
const ITALY_MAINLAND =
'M12,18 L18,11 L30,9 L44,8 L50,13 L54,20 L60,28 L70,40 L82,46 L91,52 L88,57 L79,58 ' +
'L71,63 L65,71 L63,79 L58,76 L57,67 L52,58 L45,46 L37,34 L25,27 L15,24 Z';
const ITALY_SICILY = 'M44,83 L54,81 L62,84 L58,91 L48,93 L43,88 Z';
const ITALY_SARDINIA = 'M13,55 L21,56 L22,66 L19,74 L12,72 L11,62 Z';
const RISK_COLORS: Record<string, string> = {
basso: '#4caf6e',
medio: '#d4a017',
alto: '#d9534f',
};
export function TravelScreen() {
const { player, setPlayer } = useAuth();
const { toast } = useToast();
const [cities, setCities] = useState<City[]>([]);
const [events, setEvents] = useState<WorldEvent[]>([]);
const [selected, setSelected] = useState<City | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const [c, e] = await Promise.all([api.cities(), api.events()]);
setCities(c.cities);
setEvents(e.events);
}, []);
useEffect(() => {
void reload();
}, [reload]);
const eventsByCity = useMemo(() => {
const map = new Map<string, WorldEvent[]>();
for (const event of events) {
const key = event.city?.id ?? 'global';
map.set(key, [...(map.get(key) ?? []), event]);
}
return map;
}, [events]);
const cityEvents = (cityId: string): WorldEvent[] => [
...(eventsByCity.get(cityId) ?? []),
...(eventsByCity.get('global') ?? []),
];
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');
setSelected(null);
} catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally {
setBusy(false);
}
}
return (
<div className="screen">
<h2 className="screen__title">Mappa</h2>
<div className="map-card">
<svg viewBox="0 0 100 100" className="map-svg" role="img" aria-label="Mappa delle città">
<path d={ITALY_MAINLAND} className="map-land" />
<path d={ITALY_SICILY} className="map-land" />
<path d={ITALY_SARDINIA} className="map-land" />
{cities.map((city) => {
const isCurrent = player?.currentCityId === city.id;
const hasEvent = (eventsByCity.get(city.id) ?? []).length > 0;
const color = RISK_COLORS[riskLabel(city.riskLevel)] ?? '#d4a017';
return (
<g
key={city.id}
className="map-marker"
onClick={() => setSelected(city)}
transform={`translate(${city.mapX}, ${city.mapY})`}
>
{isCurrent && <circle r="4.5" className="map-marker__pulse" fill={color} />}
<circle r="2.6" fill="#0f0f13" stroke={color} strokeWidth="0.8" />
<circle r="1" fill={isCurrent ? '#e8e4da' : color} />
{hasEvent && (
<text x="2.6" y="-2.2" fontSize="3.5">
</text>
)}
<text x="0" y="6.8" textAnchor="middle" className="map-marker__label">
{city.name}
</text>
</g>
);
})}
</svg>
<p className="muted small map-legend">
colore = rischio (verde basso, oro medio, rosso alto) · evento in corso · tocca una
città per viaggiare
</p>
</div>
<ul className="list">
{cities.map((city) => {
const isCurrent = player?.currentCityId === city.id;
return (
<li
key={city.id}
className={`list__row list__row--clickable${isCurrent ? ' is-current' : ''}`}
onClick={() => setSelected(city)}
>
<div>
<strong>{city.name}</strong>
{isCurrent && <span className="badge badge--small">sei qui</span>}
{(eventsByCity.get(city.id) ?? []).length > 0 && <span> </span>}
</div>
<span className="muted small">
{isCurrent ? '—' : formatMoney(city.travelCost)}
</span>
</li>
);
})}
</ul>
{selected && (
<div className="modal-backdrop" onClick={() => setSelected(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{selected.name}</h3>
<p className="muted small">
rischio {riskLabel(selected.riskLevel)} · economia ×{selected.economyModifier} ·
polizia ×{selected.policePressure}
</p>
{cityEvents(selected.id).length > 0 && (
<div className="banner">
{cityEvents(selected.id).map((event) => (
<span key={event.id}>
{event.title}
{!event.city && ' (globale)'}
</span>
))}
</div>
)}
{player?.currentCityId === selected.id ? (
<p className="muted">Ti trovi già in questa città.</p>
) : (
<button
type="button"
className="btn btn--primary"
disabled={busy || (player?.money ?? 0) < selected.travelCost}
onClick={() => void travelTo(selected)}
>
Viaggia · {formatMoney(selected.travelCost)}
</button>
)}
</div>
</div>
)}
</div>
);
}