diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index ba8999f..fe177d9 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -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 (
@@ -66,7 +90,12 @@ export function Layout() {
end={item.to === '/'}
className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`}
>
- {item.icon}
+
+ {item.icon}
+ {item.to === '/missions' && readyCount > 0 && (
+ {readyCount}
+ )}
+
{item.label}
))}
diff --git a/client/src/styles/global.css b/client/src/styles/global.css
index 4e6b7e4..e3f3dc3 100644
--- a/client/src/styles/global.css
+++ b/client/src/styles/global.css
@@ -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;