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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user