client: nav mission badge + CSS for map, missions, market, dashboard

Layout polls active missions every 20s and on socket events; shows a
red badge on the Missioni nav tab when missions are ready to claim.
CSS additions: SVG map styles with pulse animation, chance bar,
mission type chips, lock/abandon styles, market trend tags,
quicklinks row, nav badge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:12:21 +02:00
parent 6abd115c81
commit 48e618d543
2 changed files with 221 additions and 4 deletions
+33 -4
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { api } from '../api/api';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { getSocket } from '../realtime/socket'; import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format'; import { formatMoney } from '../shared/format';
@@ -16,6 +17,28 @@ const NAV_ITEMS = [
export function Layout() { export function Layout() {
const { player } = useAuth(); const { player } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const location = useLocation();
const [readyCount, setReadyCount] = useState(0);
// Badge sul tab Missioni: quante missioni sono pronte da riscuotere
const refreshReadyCount = useCallback(async () => {
try {
const res = await api.missionsActive();
const now = Date.now();
setReadyCount(
res.missions.filter((m) => new Date(m.completesAt).getTime() <= now).length,
);
} catch {
// non bloccare la UI per un badge
}
}, []);
useEffect(() => {
void refreshReadyCount();
const interval = setInterval(() => void refreshReadyCount(), 20_000);
return () => clearInterval(interval);
// location: il claim avviene navigando, quindi al cambio pagina si riallinea
}, [refreshReadyCount, location.pathname]);
// Notifiche realtime globali dal server // Notifiche realtime globali dal server
useEffect(() => { useEffect(() => {
@@ -24,6 +47,7 @@ export function Layout() {
const onMissionCompleted = (payload: { title?: string }) => { const onMissionCompleted = (payload: { title?: string }) => {
toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success'); toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success');
void refreshReadyCount();
}; };
const onEventStarted = (payload: { title?: string }) => { const onEventStarted = (payload: { title?: string }) => {
toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info'); toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info');
@@ -40,7 +64,7 @@ export function Layout() {
socket.off('worldEvent:started', onEventStarted); socket.off('worldEvent:started', onEventStarted);
socket.off('worldEvent:ended', onEventEnded); socket.off('worldEvent:ended', onEventEnded);
}; };
}, [toast]); }, [toast, refreshReadyCount]);
return ( return (
<div className="app"> <div className="app">
@@ -66,7 +90,12 @@ export function Layout() {
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`} className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`}
> >
<span className="bottomnav__icon">{item.icon}</span> <span className="bottomnav__icon">
{item.icon}
{item.to === '/missions' && readyCount > 0 && (
<span className="bottomnav__badge">{readyCount}</span>
)}
</span>
<span className="bottomnav__label">{item.label}</span> <span className="bottomnav__label">{item.label}</span>
</NavLink> </NavLink>
))} ))}
+188
View File
@@ -572,6 +572,194 @@ input:focus {
text-align: center; text-align: center;
} }
/* ===== Mappa ===== */
.map-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px;
}
.map-svg {
width: 100%;
height: auto;
display: block;
}
.map-land {
fill: #1d1d26;
stroke: #34343f;
stroke-width: 0.5;
}
.map-marker {
cursor: pointer;
}
.map-marker__label {
font-size: 3.4px;
fill: var(--text);
font-weight: 700;
letter-spacing: 0.05em;
paint-order: stroke;
stroke: #0f0f13;
stroke-width: 0.8px;
}
.map-marker__pulse {
opacity: 0.35;
animation: map-pulse 1.8s ease-out infinite;
transform-origin: center;
transform-box: fill-box;
}
@keyframes map-pulse {
0% {
opacity: 0.45;
transform: scale(0.6);
}
100% {
opacity: 0;
transform: scale(1.6);
}
}
.map-legend {
margin-top: 6px;
}
/* ===== Missioni: probabilità, chip, lock ===== */
.chancebar {
position: relative;
height: 16px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
}
.chancebar__fill {
position: absolute;
inset: 0 auto 0 0;
transition: width 0.3s ease;
opacity: 0.55;
}
.chancebar__text {
position: relative;
display: block;
text-align: center;
font-size: 0.68rem;
line-height: 16px;
font-weight: 700;
}
.chip {
align-self: flex-start;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-muted);
}
.chip--delivery {
border-color: var(--info);
color: var(--info);
}
.chip--theft {
border-color: var(--accent);
color: var(--accent);
}
.chip--smuggling {
border-color: var(--danger);
color: var(--danger);
}
.chip--intel {
border-color: var(--success);
color: var(--success);
}
.mission__description {
margin: 0;
font-size: 0.85rem;
font-style: italic;
color: var(--text);
opacity: 0.85;
}
.mission--locked {
opacity: 0.55;
}
.mission__locked {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-muted);
}
.mission__abandon {
align-self: flex-end;
padding: 4px 8px;
}
/* ===== Mercato: convenienza ===== */
.trend {
font-size: 0.65rem;
font-weight: 700;
border-radius: 999px;
padding: 1px 7px;
}
.trend--buy {
background: rgba(76, 175, 110, 0.15);
color: var(--success);
}
.trend--sell {
background: rgba(212, 160, 23, 0.18);
color: var(--accent);
}
/* ===== Home ===== */
.quicklinks {
display: flex;
gap: 8px;
}
.quicklinks .btn {
flex: 1;
padding: 10px 4px;
font-size: 0.8rem;
}
/* ===== Badge navigazione ===== */
.bottomnav__icon {
position: relative;
}
.bottomnav__badge {
position: absolute;
top: -4px;
right: -10px;
background: var(--danger);
color: #fff;
font-size: 0.6rem;
font-weight: 800;
border-radius: 999px;
min-width: 15px;
height: 15px;
line-height: 15px;
text-align: center;
padding: 0 3px;
}
/* ===== Toast ===== */ /* ===== Toast ===== */
.toast-stack { .toast-stack {
position: fixed; position: fixed;