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;