client: realtime socket and shared UI components

Add Socket.IO client wrapper (auto-reconnect, auth token), and
reusable components: Layout (nav bar), Toast notifications, and
Countdown timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:42:30 +02:00
parent 9e65fe5a3c
commit 95864ce392
4 changed files with 181 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
function remainingSeconds(target: string): number {
return Math.max(0, Math.ceil((new Date(target).getTime() - Date.now()) / 1000));
}
/** Conto alla rovescia verso `target`; chiama onDone una volta arrivato a zero. */
export function Countdown({ target, onDone }: { target: string; onDone?: () => void }) {
const [seconds, setSeconds] = useState(() => remainingSeconds(target));
useEffect(() => {
setSeconds(remainingSeconds(target));
const interval = setInterval(() => {
const left = remainingSeconds(target);
setSeconds(left);
if (left === 0) {
clearInterval(interval);
onDone?.();
}
}, 1000);
return () => clearInterval(interval);
}, [target]);
if (seconds === 0) return <span className="countdown countdown--done">pronta</span>;
const mm = Math.floor(seconds / 60);
const ss = String(seconds % 60).padStart(2, '0');
return (
<span className="countdown">
{mm}:{ss}
</span>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format';
import { useToast } from './Toast';
const NAV_ITEMS = [
{ to: '/', label: 'Covo', icon: '🏠' },
{ to: '/market', label: 'Mercato', icon: '⚖️' },
{ to: '/missions', label: 'Missioni', icon: '🎯' },
{ to: '/inventory', label: 'Zaino', icon: '🎒' },
{ to: '/travel', label: 'Città', icon: '🗺️' },
];
export function Layout() {
const { player } = useAuth();
const { toast } = useToast();
// Notifiche realtime globali dal server
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const onMissionCompleted = (payload: { title?: string }) => {
toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success');
};
const onEventStarted = (payload: { title?: string }) => {
toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info');
};
const onEventEnded = (payload: { title?: string }) => {
toast(`Evento terminato: ${payload.title ?? ''}`, 'info');
};
socket.on('mission:completed', onMissionCompleted);
socket.on('worldEvent:started', onEventStarted);
socket.on('worldEvent:ended', onEventEnded);
return () => {
socket.off('mission:completed', onMissionCompleted);
socket.off('worldEvent:started', onEventStarted);
socket.off('worldEvent:ended', onEventEnded);
};
}, [toast]);
return (
<div className="app">
<header className="topbar">
<span className="topbar__brand">CONTRABBANDIERI</span>
{player && (
<span className="topbar__stats">
<span className="topbar__money">{formatMoney(player.money)}</span>
<span className="topbar__level">liv. {player.level}</span>
</span>
)}
</header>
<main className="content">
<Outlet />
</main>
<nav className="bottomnav">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`}
>
<span className="bottomnav__icon">{item.icon}</span>
<span className="bottomnav__label">{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
type ToastKind = 'info' | 'success' | 'error';
type Toast = { id: number; kind: ToastKind; message: string };
type ToastContextValue = {
toast: (message: string, kind?: ToastKind) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId.current++;
setToasts((prev) => [...prev.slice(-3), { id, kind, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.kind}`}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast deve essere usato dentro ToastProvider');
return ctx;
}
+20
View File
@@ -0,0 +1,20 @@
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../config';
let socket: Socket | null = null;
/** Connette il socket realtime (path /ws) autenticandosi con il JWT. */
export function connectSocket(token: string): Socket {
disconnectSocket();
socket = io(API_URL, { path: '/ws', auth: { token } });
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectSocket(): void {
socket?.disconnect();
socket = null;
}