diff --git a/client/src/components/Countdown.tsx b/client/src/components/Countdown.tsx new file mode 100644 index 0000000..fdd4663 --- /dev/null +++ b/client/src/components/Countdown.tsx @@ -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 pronta; + + const mm = Math.floor(seconds / 60); + const ss = String(seconds % 60).padStart(2, '0'); + return ( + + {mm}:{ss} + + ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..ba8999f --- /dev/null +++ b/client/src/components/Layout.tsx @@ -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 ( +
+
+ CONTRABBANDIERI + {player && ( + + {formatMoney(player.money)} + liv. {player.level} + + )} +
+ +
+ +
+ + +
+ ); +} diff --git a/client/src/components/Toast.tsx b/client/src/components/Toast.tsx new file mode 100644 index 0000000..4f27da8 --- /dev/null +++ b/client/src/components/Toast.tsx @@ -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(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + 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 ( + + {children} +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+
+ ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast deve essere usato dentro ToastProvider'); + return ctx; +} diff --git a/client/src/realtime/socket.ts b/client/src/realtime/socket.ts new file mode 100644 index 0000000..b2d940b --- /dev/null +++ b/client/src/realtime/socket.ts @@ -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; +}