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:
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { api } from '../api/api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { getSocket } from '../realtime/socket';
|
||||
import { formatMoney } from '../shared/format';
|
||||
@@ -16,6 +17,28 @@ const NAV_ITEMS = [
|
||||
export function Layout() {
|
||||
const { player } = useAuth();
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -24,6 +47,7 @@ export function Layout() {
|
||||
|
||||
const onMissionCompleted = (payload: { title?: string }) => {
|
||||
toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success');
|
||||
void refreshReadyCount();
|
||||
};
|
||||
const onEventStarted = (payload: { title?: string }) => {
|
||||
toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info');
|
||||
@@ -40,7 +64,7 @@ export function Layout() {
|
||||
socket.off('worldEvent:started', onEventStarted);
|
||||
socket.off('worldEvent:ended', onEventEnded);
|
||||
};
|
||||
}, [toast]);
|
||||
}, [toast, refreshReadyCount]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -66,7 +90,12 @@ export function Layout() {
|
||||
end={item.to === '/'}
|
||||
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>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -572,6 +572,194 @@ input:focus {
|
||||
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-stack {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user