feat: Controllo accessi RFID completo con gestione sessioni
- Aggiunto supporto multi-pattern RFID (US/IT layout) - Implementata invalidazione sessioni al restart del server - Schermata "badge non trovato" con countdown 30s - Notifica quando badge validatore passato senza utente - Database aggiornato con badge reali di test - Layout ottimizzato per tablet orizzontale - Banner NumLock per desktop - Toggle visibilità password - Carosello benvenuto multilingua (10 lingue) - Pagina debug RFID (/debug)
This commit is contained in:
@@ -3,374 +3,400 @@
|
||||
* State Machine per il controllo accessi
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRFIDScanner } from './hooks/useRFIDScanner';
|
||||
import {
|
||||
LoadingScreen,
|
||||
ValidatorLoginScreen,
|
||||
ActiveGateScreen,
|
||||
SuccessModal,
|
||||
ErrorModal,
|
||||
} from './screens';
|
||||
import {
|
||||
getRoomInfo,
|
||||
loginValidator,
|
||||
getUserByBadge,
|
||||
requestEntry,
|
||||
ApiError,
|
||||
} from './services/api';
|
||||
import type { AppState, RoomInfo, User, ValidatorSession } from './types';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useRFIDScanner} from './hooks/useRFIDScanner';
|
||||
import {ActiveGateScreen, ErrorModal, LoadingScreen, SuccessModal, ValidatorLoginScreen,} from './screens';
|
||||
import {ApiError, getRoomInfo, getUserByBadge, loginValidator, requestEntry,} from './services/api';
|
||||
import type {AppState, RoomInfo, User, ValidatorSession} from './types';
|
||||
|
||||
// Costanti
|
||||
const VALIDATOR_BADGE = '999999';
|
||||
const VALIDATOR_PASSWORD = 'focolari';
|
||||
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti
|
||||
const USER_TIMEOUT_SECONDS = 60;
|
||||
const STORAGE_KEY = 'focolari_validator_session';
|
||||
|
||||
function App() {
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
const [appState, setAppState] = useState<AppState>('loading');
|
||||
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
|
||||
const [validatorSession, setValidatorSession] = useState<ValidatorSession | null>(null);
|
||||
const [pendingValidatorBadge, setPendingValidatorBadge] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
const [appState, setAppState] = useState<AppState>('loading');
|
||||
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
|
||||
const [validatorSession, setValidatorSession] = useState<ValidatorSession | null>(null);
|
||||
const [pendingValidatorBadge, setPendingValidatorBadge] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Modal states
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorModalMessage, setErrorModalMessage] = useState('');
|
||||
// Modal states
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successUserName, setSuccessUserName] = useState<string | undefined>(undefined);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorModalMessage, setErrorModalMessage] = useState('');
|
||||
|
||||
// ============================================
|
||||
// Session Management
|
||||
// ============================================
|
||||
// Notifica badge validatore ignorato
|
||||
const [showValidatorBadgeNotice, setShowValidatorBadgeNotice] = useState(false);
|
||||
|
||||
const saveSession = useCallback((session: ValidatorSession) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
setValidatorSession(session);
|
||||
}, []);
|
||||
// Badge non trovato (con timeout per tornare all'attesa)
|
||||
const [notFoundBadge, setNotFoundBadge] = useState<string | null>(null);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setValidatorSession(null);
|
||||
setPendingValidatorBadge(null);
|
||||
setCurrentUser(null);
|
||||
setAppState('waiting-validator');
|
||||
}, []);
|
||||
// ============================================
|
||||
// Session Management
|
||||
// ============================================
|
||||
|
||||
const loadSession = useCallback((): ValidatorSession | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
const saveSession = useCallback((session: ValidatorSession) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
setValidatorSession(session);
|
||||
}, []);
|
||||
|
||||
const session: ValidatorSession = JSON.parse(stored);
|
||||
|
||||
// Check if session is expired
|
||||
if (Date.now() > session.expiresAt) {
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// RFID Scanner Handler
|
||||
// ============================================
|
||||
|
||||
const handleRFIDScan = useCallback(async (code: string) => {
|
||||
console.log('[App] Badge scansionato:', code);
|
||||
|
||||
// Pulisci il codice
|
||||
const cleanCode = code.trim();
|
||||
|
||||
switch (appState) {
|
||||
case 'waiting-validator':
|
||||
// Verifica se è un badge validatore
|
||||
if (cleanCode === VALIDATOR_BADGE) {
|
||||
setPendingValidatorBadge(cleanCode);
|
||||
setAppState('validator-password');
|
||||
} else {
|
||||
setError('Badge validatore non riconosciuto');
|
||||
setTimeout(() => setError(undefined), 3000);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'validator-password':
|
||||
// Ignora badge durante inserimento password
|
||||
break;
|
||||
|
||||
case 'gate-active':
|
||||
case 'showing-user':
|
||||
// Se è il badge del validatore e c'è un utente a schermo
|
||||
if (cleanCode === VALIDATOR_BADGE && currentUser && currentUser.ammesso) {
|
||||
// Conferma ingresso
|
||||
await handleEntryConfirm();
|
||||
} else if (cleanCode !== VALIDATOR_BADGE) {
|
||||
// Nuovo badge partecipante - carica utente
|
||||
await handleLoadUser(cleanCode);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [appState, currentUser]);
|
||||
|
||||
// ============================================
|
||||
// Initialize RFID Scanner
|
||||
// ============================================
|
||||
|
||||
const { state: rfidState, buffer: rfidBuffer } = useRFIDScanner({
|
||||
onScan: handleRFIDScan,
|
||||
onTimeout: () => {
|
||||
console.warn('[App] RFID timeout - lettura incompleta');
|
||||
},
|
||||
disabled: appState === 'loading',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// API Handlers
|
||||
// ============================================
|
||||
|
||||
const handleLoadUser = useCallback(async (badgeCode: string) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setAppState('showing-user');
|
||||
|
||||
try {
|
||||
const user = await getUserByBadge(badgeCode);
|
||||
setCurrentUser(user);
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante il caricamento utente';
|
||||
setError(message);
|
||||
setCurrentUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEntryConfirm = useCallback(async () => {
|
||||
if (!currentUser || !validatorSession) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await requestEntry(
|
||||
currentUser.badge_code,
|
||||
VALIDATOR_PASSWORD
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.welcome_message || 'Benvenuto!');
|
||||
setShowSuccessModal(true);
|
||||
setValidatorSession(null);
|
||||
setPendingValidatorBadge(null);
|
||||
setCurrentUser(null);
|
||||
setAppState('waiting-validator');
|
||||
}, []);
|
||||
|
||||
const loadSession = useCallback((serverStartTime?: number): ValidatorSession | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const session: ValidatorSession = JSON.parse(stored);
|
||||
|
||||
// Check if session is expired
|
||||
if (Date.now() > session.expiresAt) {
|
||||
console.log('[FLOW] Session expired by time');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if server restarted (invalidate old sessions)
|
||||
if (serverStartTime && session.serverStartTime !== serverStartTime) {
|
||||
console.log('[FLOW] Session invalidated - server restarted');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// RFID Scanner Handler
|
||||
// ============================================
|
||||
|
||||
const handleRFIDScan = useCallback(async (code: string) => {
|
||||
console.log('[RFID] Badge scansionato:', code);
|
||||
|
||||
// Pulisci il codice
|
||||
const cleanCode = code.trim();
|
||||
|
||||
switch (appState) {
|
||||
case 'waiting-validator':
|
||||
// Qualsiasi badge può essere un validatore - verrà verificato con la password
|
||||
console.log('[FLOW] Transition: waiting-validator -> validator-password');
|
||||
setPendingValidatorBadge(cleanCode);
|
||||
setAppState('validator-password');
|
||||
break;
|
||||
|
||||
case 'validator-password':
|
||||
// Ignora badge durante inserimento password
|
||||
console.log('[FLOW] Badge ignorato durante inserimento password');
|
||||
break;
|
||||
|
||||
case 'gate-active':
|
||||
case 'showing-user':
|
||||
// Se è il badge del validatore attuale
|
||||
if (validatorSession && cleanCode === validatorSession.badge) {
|
||||
if (currentUser && currentUser.ammesso) {
|
||||
console.log('[FLOW] Validator badge detected - confirming entry');
|
||||
// Conferma ingresso
|
||||
await handleEntryConfirm();
|
||||
} else {
|
||||
// Badge validatore passato senza utente ammesso - mostra notifica
|
||||
console.log('[FLOW] Validator badge ignored - showing notice');
|
||||
setShowValidatorBadgeNotice(true);
|
||||
setTimeout(() => setShowValidatorBadgeNotice(false), 4000);
|
||||
}
|
||||
} else {
|
||||
console.log('[FLOW] Loading participant:', cleanCode);
|
||||
// Badge partecipante - carica utente
|
||||
// Se c'era un badge non trovato, cancellalo e carica il nuovo
|
||||
setNotFoundBadge(null);
|
||||
await handleLoadUser(cleanCode);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [appState, currentUser, validatorSession]);
|
||||
|
||||
// ============================================
|
||||
// Initialize RFID Scanner
|
||||
// ============================================
|
||||
|
||||
const {state: rfidState, buffer: rfidBuffer} = useRFIDScanner({
|
||||
onScan: handleRFIDScan,
|
||||
onTimeout: () => {
|
||||
console.warn('[App] RFID timeout - lettura incompleta');
|
||||
},
|
||||
disabled: appState === 'loading',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// API Handlers
|
||||
// ============================================
|
||||
|
||||
const handleLoadUser = useCallback(async (badgeCode: string) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setNotFoundBadge(null);
|
||||
setAppState('showing-user');
|
||||
|
||||
try {
|
||||
const user = await getUserByBadge(badgeCode);
|
||||
setCurrentUser(user);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.statusCode === 404) {
|
||||
// Utente non trovato - mostra schermata speciale con timeout
|
||||
console.log('[FLOW] Badge not found:', badgeCode);
|
||||
setNotFoundBadge(badgeCode);
|
||||
setCurrentUser(null);
|
||||
} else {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante il caricamento utente';
|
||||
setError(message);
|
||||
setCurrentUser(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEntryConfirm = useCallback(async () => {
|
||||
if (!currentUser || !validatorSession) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Salva il nome utente prima di pulirlo
|
||||
const userName = `${currentUser.nome} ${currentUser.cognome}`;
|
||||
|
||||
try {
|
||||
const response = await requestEntry(
|
||||
currentUser.badge_code,
|
||||
validatorSession.password // Uso la password dalla sessione
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
console.log('[FLOW] Entry confirmed for:', userName);
|
||||
setSuccessUserName(userName);
|
||||
setShowSuccessModal(true);
|
||||
setCurrentUser(null);
|
||||
setAppState('gate-active');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante la registrazione ingresso';
|
||||
setErrorModalMessage(message);
|
||||
setShowErrorModal(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser, validatorSession]);
|
||||
|
||||
const handlePasswordSubmit = useCallback(async (password: string) => {
|
||||
if (!pendingValidatorBadge || !roomInfo) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const response = await loginValidator(pendingValidatorBadge, password);
|
||||
|
||||
if (response.success) {
|
||||
console.log('[FLOW] Validator authenticated, badge:', pendingValidatorBadge);
|
||||
const session: ValidatorSession = {
|
||||
badge: pendingValidatorBadge,
|
||||
password: password, // Salvo la password per le conferme ingresso
|
||||
token: response.token || '',
|
||||
loginTime: Date.now(),
|
||||
expiresAt: Date.now() + SESSION_DURATION_MS,
|
||||
serverStartTime: roomInfo.server_start_time, // Per invalidare se server riparte
|
||||
};
|
||||
|
||||
saveSession(session);
|
||||
setPendingValidatorBadge(null);
|
||||
setAppState('gate-active');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante il login';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pendingValidatorBadge, saveSession, roomInfo]);
|
||||
|
||||
// ============================================
|
||||
// UI Handlers
|
||||
// ============================================
|
||||
|
||||
const handleCancelPassword = useCallback(() => {
|
||||
setPendingValidatorBadge(null);
|
||||
setError(undefined);
|
||||
setAppState('waiting-validator');
|
||||
}, []);
|
||||
|
||||
const handleCancelUser = useCallback(() => {
|
||||
setCurrentUser(null);
|
||||
setNotFoundBadge(null);
|
||||
setError(undefined);
|
||||
setAppState('gate-active');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante la registrazione ingresso';
|
||||
setErrorModalMessage(message);
|
||||
setShowErrorModal(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser, validatorSession]);
|
||||
}, []);
|
||||
|
||||
const handlePasswordSubmit = useCallback(async (password: string) => {
|
||||
if (!pendingValidatorBadge) return;
|
||||
const handleUserTimeout = useCallback(() => {
|
||||
console.log('[App] User timeout - tornando in attesa');
|
||||
setCurrentUser(null);
|
||||
setNotFoundBadge(null);
|
||||
setError(undefined);
|
||||
setAppState('gate-active');
|
||||
}, []);
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const handleSuccessModalClose = useCallback(() => {
|
||||
setShowSuccessModal(false);
|
||||
setSuccessUserName(undefined);
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const response = await loginValidator(pendingValidatorBadge, password);
|
||||
const handleErrorModalClose = useCallback(() => {
|
||||
setShowErrorModal(false);
|
||||
setErrorModalMessage('');
|
||||
}, []);
|
||||
|
||||
if (response.success) {
|
||||
const session: ValidatorSession = {
|
||||
badge: pendingValidatorBadge,
|
||||
token: response.token || '',
|
||||
loginTime: Date.now(),
|
||||
expiresAt: Date.now() + SESSION_DURATION_MS,
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
// Carica info sala
|
||||
const info = await getRoomInfo();
|
||||
setRoomInfo(info);
|
||||
|
||||
// Verifica sessione esistente (passa serverStartTime per invalidazione)
|
||||
const existingSession = loadSession(info.server_start_time);
|
||||
|
||||
if (existingSession) {
|
||||
setValidatorSession(existingSession);
|
||||
setAppState('gate-active');
|
||||
} else {
|
||||
setAppState('waiting-validator');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Impossibile connettersi al server';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
saveSession(session);
|
||||
setPendingValidatorBadge(null);
|
||||
setAppState('gate-active');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Errore durante il login';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
init();
|
||||
}, [loadSession]);
|
||||
|
||||
// Session expiry check
|
||||
useEffect(() => {
|
||||
if (!validatorSession) return;
|
||||
|
||||
const checkExpiry = () => {
|
||||
if (Date.now() > validatorSession.expiresAt) {
|
||||
clearSession();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkExpiry, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [validatorSession, clearSession]);
|
||||
|
||||
// ============================================
|
||||
// Render
|
||||
// ============================================
|
||||
|
||||
// Loading state
|
||||
if (appState === 'loading') {
|
||||
return (
|
||||
<LoadingScreen
|
||||
message="Connessione al server..."
|
||||
error={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [pendingValidatorBadge, saveSession]);
|
||||
|
||||
// ============================================
|
||||
// UI Handlers
|
||||
// ============================================
|
||||
// No room info - error
|
||||
if (!roomInfo) {
|
||||
return (
|
||||
<LoadingScreen
|
||||
error={error || 'Impossibile caricare le informazioni della sala'}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCancelPassword = useCallback(() => {
|
||||
setPendingValidatorBadge(null);
|
||||
setError(undefined);
|
||||
setAppState('waiting-validator');
|
||||
}, []);
|
||||
// Validator login screens
|
||||
if (appState === 'waiting-validator' || appState === 'validator-password') {
|
||||
return (
|
||||
<ValidatorLoginScreen
|
||||
roomInfo={roomInfo}
|
||||
rfidState={rfidState}
|
||||
rfidBuffer={rfidBuffer}
|
||||
validatorBadge={pendingValidatorBadge}
|
||||
onPasswordSubmit={handlePasswordSubmit}
|
||||
onCancel={handleCancelPassword}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCancelUser = useCallback(() => {
|
||||
setCurrentUser(null);
|
||||
setError(undefined);
|
||||
setAppState('gate-active');
|
||||
}, []);
|
||||
|
||||
const handleUserTimeout = useCallback(() => {
|
||||
console.log('[App] User timeout - tornando in attesa');
|
||||
setCurrentUser(null);
|
||||
setError(undefined);
|
||||
setAppState('gate-active');
|
||||
}, []);
|
||||
|
||||
const handleSuccessModalClose = useCallback(() => {
|
||||
setShowSuccessModal(false);
|
||||
setSuccessMessage('');
|
||||
}, []);
|
||||
|
||||
const handleErrorModalClose = useCallback(() => {
|
||||
setShowErrorModal(false);
|
||||
setErrorModalMessage('');
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
// Carica info sala
|
||||
const info = await getRoomInfo();
|
||||
setRoomInfo(info);
|
||||
|
||||
// Verifica sessione esistente
|
||||
const existingSession = loadSession();
|
||||
|
||||
if (existingSession) {
|
||||
setValidatorSession(existingSession);
|
||||
setAppState('gate-active');
|
||||
} else {
|
||||
setAppState('waiting-validator');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError
|
||||
? err.detail || err.message
|
||||
: 'Impossibile connettersi al server';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [loadSession]);
|
||||
|
||||
// Session expiry check
|
||||
useEffect(() => {
|
||||
if (!validatorSession) return;
|
||||
|
||||
const checkExpiry = () => {
|
||||
if (Date.now() > validatorSession.expiresAt) {
|
||||
clearSession();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkExpiry, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [validatorSession, clearSession]);
|
||||
|
||||
// ============================================
|
||||
// Render
|
||||
// ============================================
|
||||
|
||||
// Loading state
|
||||
if (appState === 'loading') {
|
||||
// Active gate screen
|
||||
return (
|
||||
<LoadingScreen
|
||||
message="Connessione al server..."
|
||||
error={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
<>
|
||||
<ActiveGateScreen
|
||||
roomInfo={roomInfo}
|
||||
rfidState={rfidState}
|
||||
rfidBuffer={rfidBuffer}
|
||||
currentUser={currentUser}
|
||||
notFoundBadge={notFoundBadge}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancelUser={handleCancelUser}
|
||||
onLogout={clearSession}
|
||||
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
|
||||
onUserTimeout={handleUserTimeout}
|
||||
showValidatorBadgeNotice={showValidatorBadgeNotice}
|
||||
/>
|
||||
|
||||
{/* Success Modal */}
|
||||
<SuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
onClose={handleSuccessModalClose}
|
||||
userName={successUserName}
|
||||
/>
|
||||
|
||||
{/* Error Modal */}
|
||||
<ErrorModal
|
||||
isOpen={showErrorModal}
|
||||
onClose={handleErrorModalClose}
|
||||
message={errorModalMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// No room info - error
|
||||
if (!roomInfo) {
|
||||
return (
|
||||
<LoadingScreen
|
||||
error={error || 'Impossibile caricare le informazioni della sala'}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Validator login screens
|
||||
if (appState === 'waiting-validator' || appState === 'validator-password') {
|
||||
return (
|
||||
<ValidatorLoginScreen
|
||||
roomInfo={roomInfo}
|
||||
rfidState={rfidState}
|
||||
rfidBuffer={rfidBuffer}
|
||||
validatorBadge={pendingValidatorBadge}
|
||||
onPasswordSubmit={handlePasswordSubmit}
|
||||
onCancel={handleCancelPassword}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Active gate screen
|
||||
return (
|
||||
<>
|
||||
<ActiveGateScreen
|
||||
roomInfo={roomInfo}
|
||||
rfidState={rfidState}
|
||||
rfidBuffer={rfidBuffer}
|
||||
currentUser={currentUser}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancelUser={handleCancelUser}
|
||||
onLogout={clearSession}
|
||||
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
|
||||
onUserTimeout={handleUserTimeout}
|
||||
/>
|
||||
|
||||
{/* Success Modal */}
|
||||
<SuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
onClose={handleSuccessModalClose}
|
||||
welcomeMessage={successMessage}
|
||||
userName={currentUser ? `${currentUser.nome} ${currentUser.cognome}` : undefined}
|
||||
/>
|
||||
|
||||
{/* Error Modal */}
|
||||
<ErrorModal
|
||||
isOpen={showErrorModal}
|
||||
onClose={handleErrorModalClose}
|
||||
message={errorModalMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -3,88 +3,88 @@
|
||||
*/
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses =
|
||||
'font-semibold rounded-xl transition-all duration-200 ' +
|
||||
'focus:outline-none focus:ring-4 focus:ring-offset-2 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed ' +
|
||||
'active:scale-95 touch-target';
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses =
|
||||
'font-semibold rounded-xl transition-all duration-200 ' +
|
||||
'focus:outline-none focus:ring-4 focus:ring-offset-2 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed ' +
|
||||
'active:scale-95 touch-target';
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
'bg-focolare-blue hover:bg-focolare-blue-dark text-white ' +
|
||||
'focus:ring-focolare-blue/50',
|
||||
secondary:
|
||||
'bg-gray-200 hover:bg-gray-300 text-gray-800 ' +
|
||||
'focus:ring-gray-400/50',
|
||||
danger:
|
||||
'bg-error hover:bg-error-dark text-white ' +
|
||||
'focus:ring-error/50',
|
||||
success:
|
||||
'bg-success hover:bg-success-dark text-white ' +
|
||||
'focus:ring-success/50',
|
||||
};
|
||||
const variantClasses = {
|
||||
primary:
|
||||
'bg-focolare-blue hover:bg-focolare-blue-dark text-white ' +
|
||||
'focus:ring-focolare-blue/50',
|
||||
secondary:
|
||||
'bg-gray-200 hover:bg-gray-300 text-gray-800 ' +
|
||||
'focus:ring-gray-400/50',
|
||||
danger:
|
||||
'bg-error hover:bg-error-dark text-white ' +
|
||||
'focus:ring-error/50',
|
||||
success:
|
||||
'bg-success hover:bg-success-dark text-white ' +
|
||||
'focus:ring-success/50',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-4 py-2 text-sm',
|
||||
md: 'px-6 py-3 text-base',
|
||||
lg: 'px-8 py-4 text-lg',
|
||||
};
|
||||
const sizeClasses = {
|
||||
sm: 'px-4 py-2 text-sm',
|
||||
md: 'px-6 py-3 text-base',
|
||||
lg: 'px-8 py-4 text-lg',
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
${baseClasses}
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${widthClass}
|
||||
${className}
|
||||
`.trim()}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Caricamento...
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -2,100 +2,100 @@
|
||||
* Countdown Timer Component - Focolari Voting System
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
interface CountdownTimerProps {
|
||||
/** Secondi totali */
|
||||
seconds: number;
|
||||
/** Callback quando il timer scade */
|
||||
onExpire: () => void;
|
||||
/** Pausa il timer */
|
||||
paused?: boolean;
|
||||
/** Mostra come barra di progresso */
|
||||
showBar?: boolean;
|
||||
/** Soglia warning (colore giallo) */
|
||||
warningThreshold?: number;
|
||||
/** Soglia danger (colore rosso) */
|
||||
dangerThreshold?: number;
|
||||
/** Secondi totali */
|
||||
seconds: number;
|
||||
/** Callback quando il timer scade */
|
||||
onExpire: () => void;
|
||||
/** Pausa il timer */
|
||||
paused?: boolean;
|
||||
/** Mostra come barra di progresso */
|
||||
showBar?: boolean;
|
||||
/** Soglia warning (colore giallo) */
|
||||
warningThreshold?: number;
|
||||
/** Soglia danger (colore rosso) */
|
||||
dangerThreshold?: number;
|
||||
}
|
||||
|
||||
export function CountdownTimer({
|
||||
seconds,
|
||||
onExpire,
|
||||
paused = false,
|
||||
showBar = true,
|
||||
warningThreshold = 30,
|
||||
dangerThreshold = 10,
|
||||
}: CountdownTimerProps) {
|
||||
const [remaining, setRemaining] = useState(seconds);
|
||||
seconds,
|
||||
onExpire,
|
||||
paused = false,
|
||||
showBar = true,
|
||||
warningThreshold = 30,
|
||||
dangerThreshold = 10,
|
||||
}: CountdownTimerProps) {
|
||||
const [remaining, setRemaining] = useState(seconds);
|
||||
|
||||
const formatTime = useCallback((secs: number): string => {
|
||||
const mins = Math.floor(secs / 60);
|
||||
const secsLeft = secs % 60;
|
||||
return `${mins}:${secsLeft.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
const formatTime = useCallback((secs: number): string => {
|
||||
const mins = Math.floor(secs / 60);
|
||||
const secsLeft = secs % 60;
|
||||
return `${mins}:${secsLeft.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(seconds);
|
||||
}, [seconds]);
|
||||
useEffect(() => {
|
||||
setRemaining(seconds);
|
||||
}, [seconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
onExpire();
|
||||
return 0;
|
||||
useEffect(() => {
|
||||
if (paused || remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, remaining, onExpire]);
|
||||
const timer = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
onExpire();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const getColorClass = (): string => {
|
||||
if (remaining <= dangerThreshold) {
|
||||
return 'text-error';
|
||||
}
|
||||
if (remaining <= warningThreshold) {
|
||||
return 'text-warning';
|
||||
}
|
||||
return 'text-focolare-blue';
|
||||
};
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, remaining, onExpire]);
|
||||
|
||||
const getBarColorClass = (): string => {
|
||||
if (remaining <= dangerThreshold) {
|
||||
return 'bg-error';
|
||||
}
|
||||
if (remaining <= warningThreshold) {
|
||||
return 'bg-warning';
|
||||
}
|
||||
return 'bg-focolare-blue';
|
||||
};
|
||||
const getColorClass = (): string => {
|
||||
if (remaining <= dangerThreshold) {
|
||||
return 'text-error';
|
||||
}
|
||||
if (remaining <= warningThreshold) {
|
||||
return 'text-warning';
|
||||
}
|
||||
return 'text-focolare-blue';
|
||||
};
|
||||
|
||||
const progressPercent = (remaining / seconds) * 100;
|
||||
const getBarColorClass = (): string => {
|
||||
if (remaining <= dangerThreshold) {
|
||||
return 'bg-error';
|
||||
}
|
||||
if (remaining <= warningThreshold) {
|
||||
return 'bg-warning';
|
||||
}
|
||||
return 'bg-focolare-blue';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={`text-2xl font-bold font-mono ${getColorClass()}`}>
|
||||
{formatTime(remaining)}
|
||||
</div>
|
||||
const progressPercent = (remaining / seconds) * 100;
|
||||
|
||||
{showBar && (
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ease-linear ${getBarColorClass()}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={`text-2xl font-bold font-mono ${getColorClass()}`}>
|
||||
{formatTime(remaining)}
|
||||
</div>
|
||||
|
||||
{showBar && (
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ease-linear ${getBarColorClass()}`}
|
||||
style={{width: `${progressPercent}%`}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default CountdownTimer;
|
||||
|
||||
@@ -2,45 +2,78 @@
|
||||
* Input Component - Focolari Voting System
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import {forwardRef, useState} from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
fullWidth?: boolean;
|
||||
label?: string;
|
||||
error?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, fullWidth = true, className = '', ...props }, ref) => {
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
({label, error, fullWidth = true, className = '', type, ...props}, ref) => {
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
const isPassword = type === 'password';
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`${widthClass} ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-3 text-lg
|
||||
border-2 rounded-xl
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-4 focus:ring-focolare-blue/30
|
||||
${error
|
||||
? 'border-error focus:border-error'
|
||||
: 'border-gray-300 focus:border-focolare-blue'
|
||||
}
|
||||
`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-error font-medium">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inputType = isPassword && showPassword ? 'text' : type;
|
||||
|
||||
return (
|
||||
<div className={`${widthClass} ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={ref}
|
||||
type={inputType}
|
||||
className={`
|
||||
w-full px-4 py-3 text-lg
|
||||
border-2 rounded-xl
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-4 focus:ring-focolare-blue/30
|
||||
${isPassword ? 'pr-12' : ''}
|
||||
${error
|
||||
? 'border-error focus:border-error'
|
||||
: 'border-gray-300 focus:border-focolare-blue'
|
||||
}
|
||||
`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Nascondi password' : 'Mostra password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
// Icona occhio barrato (nascondi)
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
) : (
|
||||
// Icona occhio (mostra)
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-error font-medium">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
@@ -5,40 +5,40 @@
|
||||
import FocolareLogo from '../assets/FocolareMovLogo.jpg';
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showText?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ size = 'md', showText = true }: LogoProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-10 w-10',
|
||||
md: 'h-14 w-14',
|
||||
lg: 'h-20 w-20',
|
||||
};
|
||||
export function Logo({size = 'md', showText = true}: LogoProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-10 w-10',
|
||||
md: 'h-14 w-14',
|
||||
lg: 'h-20 w-20',
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl',
|
||||
};
|
||||
const textSizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={FocolareLogo}
|
||||
alt="Movimento dei Focolari"
|
||||
className={`${sizeClasses[size]} rounded-lg object-contain shadow-md`}
|
||||
/>
|
||||
{showText && (
|
||||
<div className="flex flex-col">
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={FocolareLogo}
|
||||
alt="Movimento dei Focolari"
|
||||
className={`${sizeClasses[size]} rounded-lg object-contain shadow-md`}
|
||||
/>
|
||||
{showText && (
|
||||
<div className="flex flex-col">
|
||||
<span className={`${textSizeClasses[size]} font-bold text-focolare-blue`}>
|
||||
Movimento dei Focolari
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">Sistema Votazioni</span>
|
||||
<span className="text-sm text-gray-500">Sistema Votazioni</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default Logo;
|
||||
|
||||
@@ -2,79 +2,79 @@
|
||||
* Modal Component - Focolari Voting System
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
variant?: 'success' | 'error' | 'info';
|
||||
autoCloseMs?: number;
|
||||
fullscreen?: boolean;
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
variant?: 'success' | 'error' | 'info';
|
||||
autoCloseMs?: number;
|
||||
fullscreen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
variant = 'info',
|
||||
autoCloseMs,
|
||||
fullscreen = false,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
// Auto-close functionality
|
||||
useEffect(() => {
|
||||
if (!isOpen || !autoCloseMs || !onClose) {
|
||||
return;
|
||||
isOpen,
|
||||
onClose,
|
||||
variant = 'info',
|
||||
autoCloseMs,
|
||||
fullscreen = false,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
// Auto-close functionality
|
||||
useEffect(() => {
|
||||
if (!isOpen || !autoCloseMs || !onClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, autoCloseMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, autoCloseMs, onClose]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, autoCloseMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, autoCloseMs, onClose]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
const variantClasses = {
|
||||
success: 'bg-success',
|
||||
error: 'bg-error',
|
||||
info: 'bg-focolare-blue',
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
const overlayClass = fullscreen
|
||||
? `fixed inset-0 z-50 ${variantClasses[variant]} animate-fade-in`
|
||||
: 'fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fade-in';
|
||||
|
||||
const variantClasses = {
|
||||
success: 'bg-success',
|
||||
error: 'bg-error',
|
||||
info: 'bg-focolare-blue',
|
||||
};
|
||||
const contentClass = fullscreen
|
||||
? 'h-full w-full flex items-center justify-center'
|
||||
: 'glass rounded-2xl shadow-2xl max-w-lg w-full p-6 animate-slide-up';
|
||||
|
||||
const overlayClass = fullscreen
|
||||
? `fixed inset-0 z-50 ${variantClasses[variant]} animate-fade-in`
|
||||
: 'fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fade-in';
|
||||
|
||||
const contentClass = fullscreen
|
||||
? 'h-full w-full flex items-center justify-center'
|
||||
: 'glass rounded-2xl shadow-2xl max-w-lg w-full p-6 animate-slide-up';
|
||||
|
||||
return (
|
||||
<div className={overlayClass} onClick={onClose}>
|
||||
<div
|
||||
className={contentClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={overlayClass} onClick={onClose}>
|
||||
<div
|
||||
className={contentClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
|
||||
132
frontend/src/components/NumLockBanner.tsx
Normal file
132
frontend/src/components/NumLockBanner.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* NumLock Banner Component - Focolari Voting System
|
||||
*
|
||||
* Mostra un banner di avviso su browser desktop quando NumLock
|
||||
* potrebbe essere necessario per il corretto funzionamento del lettore RFID.
|
||||
* Non viene mostrato su dispositivi touch (tablet, smartphone).
|
||||
*/
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface NumLockBannerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NumLockBanner({className = ''}: NumLockBannerProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [numLockState, setNumLockState] = useState<boolean | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Rileva se siamo su desktop (non touch device)
|
||||
useEffect(() => {
|
||||
const checkIfDesktop = () => {
|
||||
// Controlla se è un dispositivo touch
|
||||
const isTouchDevice =
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
// @ts-expect-error - msMaxTouchPoints è specifico di IE/Edge
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Controlla user agent per mobile/tablet
|
||||
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
const isMobileUA = mobileRegex.test(navigator.userAgent);
|
||||
|
||||
// È desktop solo se non è touch E non ha UA mobile
|
||||
setIsDesktop(!isTouchDevice && !isMobileUA);
|
||||
};
|
||||
|
||||
checkIfDesktop();
|
||||
}, []);
|
||||
|
||||
// Ascolta eventi tastiera per rilevare stato NumLock
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// getModifierState restituisce lo stato di NumLock
|
||||
const numLock = event.getModifierState('NumLock');
|
||||
setNumLockState(numLock);
|
||||
};
|
||||
|
||||
// Aggiungi listener
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
// Non mostrare su mobile/tablet o se dismissato
|
||||
if (!isDesktop || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-amber-100 border border-amber-300 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icona */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<svg
|
||||
className="w-5 h-5 text-amber-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-amber-800">
|
||||
⌨️ Modalità Desktop Rilevata
|
||||
</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Per il corretto funzionamento del lettore RFID, assicurati che il tasto
|
||||
<strong> Bloc Num (NumLock) </strong> sia attivo.
|
||||
</p>
|
||||
|
||||
{/* Stato NumLock */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-amber-700">Stato attuale:</span>
|
||||
{numLockState === null ? (
|
||||
<span className="text-sm text-amber-600 italic">
|
||||
Premi un tasto per rilevare...
|
||||
</span>
|
||||
) : numLockState ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
NumLock ATTIVO ✓
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-red-700 bg-red-100 px-2 py-0.5 rounded">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
|
||||
NumLock DISATTIVO ✗
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pulsante chiudi */}
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="flex-shrink-0 text-amber-500 hover:text-amber-700 transition-colors"
|
||||
aria-label="Chiudi avviso"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumLockBanner;
|
||||
@@ -3,31 +3,31 @@
|
||||
* Mostra lo stato del lettore RFID
|
||||
*/
|
||||
|
||||
import type { RFIDScannerState } from '../types';
|
||||
import type {RFIDScannerState} from '../types';
|
||||
|
||||
interface RFIDStatusProps {
|
||||
state: RFIDScannerState;
|
||||
buffer?: string;
|
||||
state: RFIDScannerState;
|
||||
buffer?: string;
|
||||
}
|
||||
|
||||
export function RFIDStatus({ state, buffer }: RFIDStatusProps) {
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<div className="h-3 w-3 rounded-full bg-gray-300" />
|
||||
<span className="text-sm">RFID Pronto</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function RFIDStatus({state, buffer}: RFIDStatusProps) {
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<div className="h-3 w-3 rounded-full bg-gray-300"/>
|
||||
<span className="text-sm">RFID Pronto</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-focolare-orange">
|
||||
<div className="h-3 w-3 rounded-full bg-focolare-orange animate-pulse" />
|
||||
<span className="text-sm font-semibold">
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-focolare-orange">
|
||||
<div className="h-3 w-3 rounded-full bg-focolare-orange animate-pulse"/>
|
||||
<span className="text-sm font-semibold">
|
||||
Lettura in corso... {buffer && `(${buffer.length} caratteri)`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RFIDStatus;
|
||||
|
||||
@@ -2,99 +2,99 @@
|
||||
* User Card Component - Focolari Voting System
|
||||
*/
|
||||
|
||||
import type { User } from '../types';
|
||||
import type {User} from '../types';
|
||||
|
||||
interface UserCardProps {
|
||||
user: User;
|
||||
size?: 'compact' | 'full';
|
||||
user: User;
|
||||
size?: 'compact' | 'full';
|
||||
}
|
||||
|
||||
export function UserCard({ user, size = 'full' }: UserCardProps) {
|
||||
const roleColors: Record<string, string> = {
|
||||
Votante: 'bg-focolare-blue text-white',
|
||||
Tecnico: 'bg-focolare-orange text-white',
|
||||
Ospite: 'bg-gray-500 text-white',
|
||||
};
|
||||
export function UserCard({user, size = 'full'}: UserCardProps) {
|
||||
const roleColors: Record<string, string> = {
|
||||
Votante: 'bg-focolare-blue text-white',
|
||||
Tecnico: 'bg-focolare-orange text-white',
|
||||
Ospite: 'bg-gray-500 text-white',
|
||||
};
|
||||
|
||||
const statusClass = user.ammesso
|
||||
? 'border-success bg-success/10'
|
||||
: 'border-error bg-error/10 animate-pulse-error';
|
||||
const statusClass = user.ammesso
|
||||
? 'border-success bg-success/10'
|
||||
: 'border-error bg-error/10 animate-pulse-error';
|
||||
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
<div className={`rounded-xl border-2 p-4 ${statusClass} animate-slide-up`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.url_foto}
|
||||
alt={`${user.nome} ${user.cognome}`}
|
||||
className="h-16 w-16 rounded-full object-cover shadow-md"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://via.placeholder.com/100?text=' + user.nome.charAt(0);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">
|
||||
{user.nome} {user.cognome}
|
||||
</h3>
|
||||
<span className={`inline-block px-2 py-1 text-sm rounded ${roleColors[user.ruolo]}`}>
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
<div className={`rounded-xl border-2 p-4 ${statusClass} animate-slide-up`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.url_foto}
|
||||
alt={`${user.nome} ${user.cognome}`}
|
||||
className="h-16 w-16 rounded-full object-cover shadow-md"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://via.placeholder.com/100?text=' + user.nome.charAt(0);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">
|
||||
{user.nome} {user.cognome}
|
||||
</h3>
|
||||
<span className={`inline-block px-2 py-1 text-sm rounded ${roleColors[user.ruolo]}`}>
|
||||
{user.ruolo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border-4 p-6 ${statusClass} animate-slide-up`}>
|
||||
{/* Foto e Dati Principali */}
|
||||
<div className="flex flex-col items-center gap-4 md:flex-row md:items-start md:gap-6">
|
||||
<img
|
||||
src={user.url_foto}
|
||||
alt={`${user.nome} ${user.cognome}`}
|
||||
className="h-32 w-32 rounded-2xl object-cover shadow-lg md:h-40 md:w-40"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://via.placeholder.com/200?text=' + user.nome.charAt(0);
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<div className={`rounded-2xl border-4 p-6 ${statusClass} animate-slide-up`}>
|
||||
{/* Foto e Dati Principali */}
|
||||
<div className="flex flex-col items-center gap-4 md:flex-row md:items-start md:gap-6">
|
||||
<img
|
||||
src={user.url_foto}
|
||||
alt={`${user.nome} ${user.cognome}`}
|
||||
className="h-32 w-32 rounded-2xl object-cover shadow-lg md:h-40 md:w-40"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://via.placeholder.com/200?text=' + user.nome.charAt(0);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center text-center md:items-start md:text-left">
|
||||
<h2 className="text-3xl font-bold text-gray-800 md:text-4xl">
|
||||
{user.nome} {user.cognome}
|
||||
</h2>
|
||||
<div className="flex flex-col items-center text-center md:items-start md:text-left">
|
||||
<h2 className="text-3xl font-bold text-gray-800 md:text-4xl">
|
||||
{user.nome} {user.cognome}
|
||||
</h2>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${roleColors[user.ruolo]}`}>
|
||||
{user.ruolo}
|
||||
</span>
|
||||
|
||||
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${
|
||||
user.ammesso
|
||||
? 'bg-success text-white'
|
||||
: 'bg-error text-white animate-blink'
|
||||
}`}>
|
||||
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${
|
||||
user.ammesso
|
||||
? 'bg-success text-white'
|
||||
: 'bg-error text-white animate-blink'
|
||||
}`}>
|
||||
{user.ammesso ? '✓ AMMESSO' : '✗ NON AMMESSO'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-gray-500">
|
||||
Badge: <span className="font-mono font-semibold">{user.badge_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-500">
|
||||
Badge: <span className="font-mono font-semibold">{user.badge_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
{user.warning && (
|
||||
<div className="mt-4 rounded-xl bg-error/20 border-2 border-error p-4">
|
||||
<p className="text-lg font-bold text-error text-center">
|
||||
⚠️ {user.warning}
|
||||
</p>
|
||||
{/* Warning Box */}
|
||||
{user.warning && (
|
||||
<div className="mt-4 rounded-xl bg-error/20 border-2 border-error p-4">
|
||||
<p className="text-lg font-bold text-error text-center">
|
||||
⚠️ {user.warning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCard;
|
||||
|
||||
100
frontend/src/components/WelcomeCarousel.tsx
Normal file
100
frontend/src/components/WelcomeCarousel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Welcome Carousel Component - Focolari Voting System
|
||||
*
|
||||
* Carosello automatico di messaggi di benvenuto multilingua.
|
||||
* Scorre automaticamente ogni 800ms durante la visualizzazione.
|
||||
*/
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface WelcomeMessage {
|
||||
lang: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const WELCOME_MESSAGES: WelcomeMessage[] = [
|
||||
{lang: 'it', text: 'Benvenuto!'},
|
||||
{lang: 'en', text: 'Welcome!'},
|
||||
{lang: 'fr', text: 'Bienvenue!'},
|
||||
{lang: 'de', text: 'Willkommen!'},
|
||||
{lang: 'es', text: '¡Bienvenido!'},
|
||||
{lang: 'pt', text: 'Bem-vindo!'},
|
||||
{lang: 'zh', text: '欢迎!'},
|
||||
{lang: 'ja', text: 'ようこそ!'},
|
||||
{lang: 'ar', text: '!مرحباً'},
|
||||
{lang: 'ru', text: 'Добро пожаловать!'},
|
||||
];
|
||||
|
||||
const CAROUSEL_INTERVAL_MS = 800;
|
||||
|
||||
interface WelcomeCarouselProps {
|
||||
/** Se true, il carosello è in pausa */
|
||||
paused?: boolean;
|
||||
/** Nome dell'utente da visualizzare (opzionale) */
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export function WelcomeCarousel({paused = false, userName}: WelcomeCarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setIsTransitioning(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % WELCOME_MESSAGES.length);
|
||||
setIsTransitioning(false);
|
||||
}, 150); // Durata transizione fade
|
||||
}, CAROUSEL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused]);
|
||||
|
||||
const currentMessage = WELCOME_MESSAGES[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
{/* Messaggio di benvenuto */}
|
||||
<div
|
||||
className={`transition-opacity duration-150 ${
|
||||
isTransitioning ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<h1 className="text-6xl md:text-8xl font-bold text-white mb-4 drop-shadow-lg">
|
||||
{currentMessage.text}
|
||||
</h1>
|
||||
<p className="text-xl text-white/70 uppercase tracking-wider">
|
||||
{currentMessage.lang.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nome utente */}
|
||||
{userName && (
|
||||
<div className="mt-8 pt-8 border-t border-white/30">
|
||||
<p className="text-3xl md:text-4xl text-white font-medium">
|
||||
{userName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicatori */}
|
||||
<div className="flex gap-2 mt-8">
|
||||
{WELCOME_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full transition-all duration-300 ${
|
||||
index === currentIndex
|
||||
? 'bg-white w-6'
|
||||
: 'bg-white/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WelcomeCarousel;
|
||||
@@ -1,8 +1,10 @@
|
||||
// Components barrel export
|
||||
export { Logo } from './Logo';
|
||||
export { UserCard } from './UserCard';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
export { Modal } from './Modal';
|
||||
export { RFIDStatus } from './RFIDStatus';
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export {Logo} from './Logo';
|
||||
export {UserCard} from './UserCard';
|
||||
export {CountdownTimer} from './CountdownTimer';
|
||||
export {Modal} from './Modal';
|
||||
export {RFIDStatus} from './RFIDStatus';
|
||||
export {Button} from './Button';
|
||||
export {Input} from './Input';
|
||||
export {WelcomeCarousel} from './WelcomeCarousel';
|
||||
export {NumLockBanner} from './NumLockBanner';
|
||||
|
||||
@@ -1,219 +1,335 @@
|
||||
/**
|
||||
* Focolari Voting System - RFID Scanner Hook
|
||||
* Focolari Voting System - RFID Scanner Hook (v2 - Multi-Pattern)
|
||||
*
|
||||
* Questo hook gestisce la lettura di badge RFID tramite lettori USB
|
||||
* che emulano una tastiera. Il protocollo prevede:
|
||||
* - Carattere di inizio: `;`
|
||||
* - Carattere di fine: `?`
|
||||
* - Esempio: `;00012345?`
|
||||
* che emulano una tastiera. Supporta pattern multipli per diversi layout:
|
||||
* - Layout US: `;` → `?`
|
||||
* - Layout IT: `ò` → `_`
|
||||
*
|
||||
* NOTA: Il lettore RFID invia Enter (\n) dopo l'ultimo carattere.
|
||||
* L'hook lo gestisce ignorando l'Enter immediatamente dopo il completamento.
|
||||
*
|
||||
* L'hook funziona indipendentemente dal focus dell'applicazione.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { RFIDScannerState, RFIDScanResult } from '../types';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import type {RFIDScannerState, RFIDScanResult} from '../types';
|
||||
|
||||
// Costanti
|
||||
const START_SENTINEL = ';';
|
||||
const END_SENTINEL = '?';
|
||||
const TIMEOUT_MS = 3000; // 3 secondi di timeout
|
||||
// ============================================
|
||||
// CONFIGURAZIONE PATTERN RFID
|
||||
// ============================================
|
||||
|
||||
export interface RFIDPattern {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export const VALID_PATTERNS: RFIDPattern[] = [
|
||||
{name: 'US', start: ';', end: '?'},
|
||||
{name: 'IT', start: 'ò', end: '_'},
|
||||
];
|
||||
|
||||
const TIMEOUT_MS = 2500; // 2.5 secondi di timeout
|
||||
const ENTER_GRACE_PERIOD_MS = 100; // Ignora Enter entro 100ms dal completamento
|
||||
|
||||
// ============================================
|
||||
// LOGGING
|
||||
// ============================================
|
||||
|
||||
const log = (message: string, ...args: unknown[]) => {
|
||||
console.log(`[RFID] ${message}`, ...args);
|
||||
};
|
||||
|
||||
const logWarn = (message: string, ...args: unknown[]) => {
|
||||
console.warn(`[RFID] ${message}`, ...args);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// TIPI
|
||||
// ============================================
|
||||
|
||||
interface UseRFIDScannerOptions {
|
||||
/** Callback chiamato quando un badge viene letto con successo */
|
||||
onScan: (code: string) => void;
|
||||
/** Callback opzionale chiamato in caso di timeout */
|
||||
onTimeout?: () => void;
|
||||
/** Callback opzionale chiamato quando inizia la scansione */
|
||||
onScanStart?: () => void;
|
||||
/** Se true, previene l'input nei campi di testo durante la scansione */
|
||||
preventDefaultOnScan?: boolean;
|
||||
/** Se true, l'hook è disabilitato */
|
||||
disabled?: boolean;
|
||||
/** Callback chiamato quando un badge viene letto con successo */
|
||||
onScan: (code: string) => void;
|
||||
/** Callback opzionale chiamato in caso di timeout */
|
||||
onTimeout?: () => void;
|
||||
/** Callback opzionale chiamato quando inizia la scansione */
|
||||
onScanStart?: () => void;
|
||||
/** Se true, previene l'input nei campi di testo durante la scansione */
|
||||
preventDefaultOnScan?: boolean;
|
||||
/** Se true, l'hook è disabilitato */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseRFIDScannerReturn {
|
||||
/** Stato corrente dello scanner */
|
||||
state: RFIDScannerState;
|
||||
/** Buffer corrente (solo per debug) */
|
||||
buffer: string;
|
||||
/** Ultimo codice scansionato */
|
||||
lastScan: RFIDScanResult | null;
|
||||
/** Reset manuale dello scanner */
|
||||
reset: () => void;
|
||||
export interface UseRFIDScannerReturn {
|
||||
/** Stato corrente dello scanner */
|
||||
state: RFIDScannerState;
|
||||
/** Buffer corrente (solo per debug) */
|
||||
buffer: string;
|
||||
/** Ultimo codice scansionato */
|
||||
lastScan: RFIDScanResult | null;
|
||||
/** Pattern attualmente in uso (solo durante scanning) */
|
||||
activePattern: RFIDPattern | null;
|
||||
/** Reset manuale dello scanner */
|
||||
reset: () => void;
|
||||
/** Ultimi eventi tastiera (per debug) */
|
||||
keyLog: KeyLogEntry[];
|
||||
}
|
||||
|
||||
export interface KeyLogEntry {
|
||||
key: string;
|
||||
code: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const MAX_KEY_LOG = 20;
|
||||
|
||||
// ============================================
|
||||
// HOOK
|
||||
// ============================================
|
||||
|
||||
export function useRFIDScanner({
|
||||
onScan,
|
||||
onTimeout,
|
||||
onScanStart,
|
||||
preventDefaultOnScan = true,
|
||||
disabled = false,
|
||||
}: UseRFIDScannerOptions): UseRFIDScannerReturn {
|
||||
const [state, setState] = useState<RFIDScannerState>('idle');
|
||||
const [buffer, setBuffer] = useState<string>('');
|
||||
const [lastScan, setLastScan] = useState<RFIDScanResult | null>(null);
|
||||
onScan,
|
||||
onTimeout,
|
||||
onScanStart,
|
||||
preventDefaultOnScan = true,
|
||||
disabled = false,
|
||||
}: UseRFIDScannerOptions): UseRFIDScannerReturn {
|
||||
const [state, setState] = useState<RFIDScannerState>('idle');
|
||||
const [buffer, setBuffer] = useState<string>('');
|
||||
const [lastScan, setLastScan] = useState<RFIDScanResult | null>(null);
|
||||
const [activePattern, setActivePattern] = useState<RFIDPattern | null>(null);
|
||||
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
||||
|
||||
// Refs per mantenere i valori aggiornati nei callback
|
||||
const bufferRef = useRef<string>('');
|
||||
const stateRef = useRef<RFIDScannerState>('idle');
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
// Refs per mantenere i valori aggiornati nei callback
|
||||
const bufferRef = useRef<string>('');
|
||||
const stateRef = useRef<RFIDScannerState>('idle');
|
||||
const activePatternRef = useRef<RFIDPattern | null>(null);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const lastCompletionRef = useRef<number>(0);
|
||||
|
||||
// Sync refs con state
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
// Sync refs con state
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
/**
|
||||
* Pulisce il timeout attivo
|
||||
*/
|
||||
const clearScanTimeout = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
activePatternRef.current = activePattern;
|
||||
}, [activePattern]);
|
||||
|
||||
/**
|
||||
* Resetta lo scanner allo stato idle
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
clearScanTimeout();
|
||||
setState('idle');
|
||||
setBuffer('');
|
||||
bufferRef.current = '';
|
||||
stateRef.current = 'idle';
|
||||
}, [clearScanTimeout]);
|
||||
/**
|
||||
* Aggiunge un evento al log tastiera
|
||||
*/
|
||||
const addKeyLog = useCallback((key: string, code: string) => {
|
||||
setKeyLog(prev => {
|
||||
const newEntry: KeyLogEntry = {key, code, timestamp: Date.now()};
|
||||
const updated = [newEntry, ...prev].slice(0, MAX_KEY_LOG);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Avvia il timeout di sicurezza
|
||||
*/
|
||||
const startTimeout = useCallback(() => {
|
||||
clearScanTimeout();
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
console.warn('[RFID Scanner] Timeout - lettura incompleta scartata');
|
||||
onTimeout?.();
|
||||
reset();
|
||||
}, TIMEOUT_MS);
|
||||
}, [clearScanTimeout, onTimeout, reset]);
|
||||
|
||||
/**
|
||||
* Handler principale per gli eventi keydown
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
|
||||
// Ignora tasti speciali (frecce, funzione, ecc.)
|
||||
if (key.length > 1 && key !== START_SENTINEL && key !== END_SENTINEL) {
|
||||
// Eccezione per Backspace in stato scanning: ignora ma non resetta
|
||||
if (key === 'Backspace' && stateRef.current === 'scanning') {
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
/**
|
||||
* Pulisce il timeout attivo
|
||||
*/
|
||||
const clearScanTimeout = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// STATO IDLE: attende il carattere di inizio
|
||||
if (stateRef.current === 'idle') {
|
||||
if (key === START_SENTINEL) {
|
||||
console.log('[RFID Scanner] Start sentinel rilevato - inizio scansione');
|
||||
/**
|
||||
* Resetta lo scanner allo stato idle
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
clearScanTimeout();
|
||||
setState('idle');
|
||||
setBuffer('');
|
||||
setActivePattern(null);
|
||||
bufferRef.current = '';
|
||||
stateRef.current = 'idle';
|
||||
activePatternRef.current = null;
|
||||
}, [clearScanTimeout]);
|
||||
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
/**
|
||||
* Avvia il timeout di sicurezza
|
||||
*/
|
||||
const startTimeout = useCallback(() => {
|
||||
clearScanTimeout();
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
logWarn('Buffer timeout - clearing data');
|
||||
onTimeout?.();
|
||||
reset();
|
||||
}, TIMEOUT_MS);
|
||||
}, [clearScanTimeout, onTimeout, reset]);
|
||||
|
||||
setState('scanning');
|
||||
setBuffer('');
|
||||
bufferRef.current = '';
|
||||
startTimeout();
|
||||
onScanStart?.();
|
||||
}
|
||||
// Altrimenti ignora il tasto (comportamento normale)
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Trova il pattern che corrisponde al carattere start
|
||||
*/
|
||||
const findPatternByStart = useCallback((char: string): RFIDPattern | undefined => {
|
||||
return VALID_PATTERNS.find(p => p.start === char);
|
||||
}, []);
|
||||
|
||||
// STATO SCANNING: accumula i caratteri o termina
|
||||
if (stateRef.current === 'scanning') {
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
/**
|
||||
* Handler principale per gli eventi keydown
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === END_SENTINEL) {
|
||||
// Fine della scansione
|
||||
clearScanTimeout();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
const code = event.code;
|
||||
|
||||
const scannedCode = bufferRef.current.trim();
|
||||
// Log per debug
|
||||
addKeyLog(key, code);
|
||||
|
||||
if (scannedCode.length > 0) {
|
||||
console.log('[RFID Scanner] Codice scansionato:', scannedCode);
|
||||
// Gestione Enter dopo completamento (grace period)
|
||||
if (key === 'Enter' && Date.now() - lastCompletionRef.current < ENTER_GRACE_PERIOD_MS) {
|
||||
log('Enter ignorato (post-completion grace period)');
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result: RFIDScanResult = {
|
||||
code: scannedCode,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Gestione ESC: annulla scansione in corso
|
||||
if (key === 'Escape' && stateRef.current === 'scanning') {
|
||||
log('Scansione annullata con ESC');
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastScan(result);
|
||||
onScan(scannedCode);
|
||||
} else {
|
||||
console.warn('[RFID Scanner] Codice vuoto scartato');
|
||||
}
|
||||
// Ignora tasti speciali (frecce, funzione, ecc.) ma non i sentinel
|
||||
const isStartSentinel = VALID_PATTERNS.some(p => p.start === key);
|
||||
const isEndSentinel = activePatternRef.current?.end === key;
|
||||
|
||||
reset();
|
||||
} else if (key === START_SENTINEL) {
|
||||
// Nuovo start sentinel durante scansione: resetta e ricomincia
|
||||
console.log('[RFID Scanner] Nuovo start sentinel - reset buffer');
|
||||
setBuffer('');
|
||||
bufferRef.current = '';
|
||||
startTimeout();
|
||||
} else {
|
||||
// Accumula il carattere nel buffer
|
||||
const newBuffer = bufferRef.current + key;
|
||||
setBuffer(newBuffer);
|
||||
bufferRef.current = newBuffer;
|
||||
}
|
||||
}
|
||||
if (key.length > 1 && !isStartSentinel && !isEndSentinel) {
|
||||
// Eccezione per Backspace/Enter in stato scanning: ignora ma non resetta
|
||||
if ((key === 'Backspace' || key === 'Enter') && stateRef.current === 'scanning') {
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// STATO IDLE: attende un carattere start di qualsiasi pattern
|
||||
if (stateRef.current === 'idle') {
|
||||
const pattern = findPatternByStart(key);
|
||||
|
||||
if (pattern) {
|
||||
log(`Start sentinel detected: '${key}' (pattern ${pattern.name})`);
|
||||
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setState('scanning');
|
||||
setActivePattern(pattern);
|
||||
setBuffer('');
|
||||
bufferRef.current = '';
|
||||
activePatternRef.current = pattern;
|
||||
startTimeout();
|
||||
onScanStart?.();
|
||||
}
|
||||
// Altrimenti ignora il tasto (comportamento normale)
|
||||
return;
|
||||
}
|
||||
|
||||
// STATO SCANNING: accumula i caratteri o termina
|
||||
if (stateRef.current === 'scanning') {
|
||||
if (preventDefaultOnScan) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const currentPattern = activePatternRef.current;
|
||||
|
||||
// Verifica se è l'end sentinel del pattern attivo
|
||||
if (currentPattern && key === currentPattern.end) {
|
||||
// Fine della scansione
|
||||
clearScanTimeout();
|
||||
lastCompletionRef.current = Date.now();
|
||||
|
||||
const scannedCode = bufferRef.current.trim();
|
||||
|
||||
if (scannedCode.length > 0) {
|
||||
log(`Scan complete (pattern ${currentPattern.name}): ${scannedCode}`);
|
||||
|
||||
const result: RFIDScanResult = {
|
||||
code: scannedCode,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setLastScan(result);
|
||||
onScan(scannedCode);
|
||||
} else {
|
||||
logWarn('Empty code discarded');
|
||||
}
|
||||
|
||||
reset();
|
||||
} else if (findPatternByStart(key)) {
|
||||
// Nuovo start sentinel durante scansione: resetta e ricomincia con nuovo pattern
|
||||
const newPattern = findPatternByStart(key)!;
|
||||
log(`New start sentinel during scan - switching to pattern ${newPattern.name}`);
|
||||
setBuffer('');
|
||||
bufferRef.current = '';
|
||||
setActivePattern(newPattern);
|
||||
activePatternRef.current = newPattern;
|
||||
startTimeout();
|
||||
} else {
|
||||
// Accumula il carattere nel buffer
|
||||
const newBuffer = bufferRef.current + key;
|
||||
setBuffer(newBuffer);
|
||||
bufferRef.current = newBuffer;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Aggiungi listener globale
|
||||
window.addEventListener('keydown', handleKeyDown, {capture: true});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, {capture: true});
|
||||
clearScanTimeout();
|
||||
};
|
||||
}, [
|
||||
disabled,
|
||||
onScan,
|
||||
onScanStart,
|
||||
preventDefaultOnScan,
|
||||
clearScanTimeout,
|
||||
reset,
|
||||
startTimeout,
|
||||
findPatternByStart,
|
||||
addKeyLog,
|
||||
]);
|
||||
|
||||
// Cleanup al unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScanTimeout();
|
||||
};
|
||||
}, [clearScanTimeout]);
|
||||
|
||||
return {
|
||||
state,
|
||||
buffer,
|
||||
lastScan,
|
||||
activePattern,
|
||||
reset,
|
||||
keyLog,
|
||||
};
|
||||
|
||||
// Aggiungi listener globale
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
clearScanTimeout();
|
||||
};
|
||||
}, [
|
||||
disabled,
|
||||
onScan,
|
||||
onScanStart,
|
||||
preventDefaultOnScan,
|
||||
clearScanTimeout,
|
||||
reset,
|
||||
startTimeout,
|
||||
]);
|
||||
|
||||
// Cleanup al unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScanTimeout();
|
||||
};
|
||||
}, [clearScanTimeout]);
|
||||
|
||||
return {
|
||||
state,
|
||||
buffer,
|
||||
lastScan,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export default useRFIDScanner;
|
||||
|
||||
@@ -5,25 +5,25 @@
|
||||
============================================ */
|
||||
|
||||
@theme {
|
||||
/* Colori Istituzionali */
|
||||
--color-focolare-blue: #0072CE;
|
||||
--color-focolare-blue-dark: #005BA1;
|
||||
--color-focolare-blue-light: #3D9BE0;
|
||||
/* Colori Istituzionali */
|
||||
--color-focolare-blue: #0072CE;
|
||||
--color-focolare-blue-dark: #005BA1;
|
||||
--color-focolare-blue-light: #3D9BE0;
|
||||
|
||||
--color-focolare-orange: #F5A623;
|
||||
--color-focolare-orange-dark: #D4891C;
|
||||
--color-focolare-orange-light: #FFB84D;
|
||||
--color-focolare-orange: #F5A623;
|
||||
--color-focolare-orange-dark: #D4891C;
|
||||
--color-focolare-orange-light: #FFB84D;
|
||||
|
||||
--color-focolare-yellow: #FFD700;
|
||||
--color-focolare-yellow-dark: #CCA300;
|
||||
--color-focolare-yellow-light: #FFE44D;
|
||||
--color-focolare-yellow: #FFD700;
|
||||
--color-focolare-yellow-dark: #CCA300;
|
||||
--color-focolare-yellow-light: #FFE44D;
|
||||
|
||||
/* Stati */
|
||||
--color-success: #22C55E;
|
||||
--color-success-dark: #16A34A;
|
||||
--color-error: #EF4444;
|
||||
--color-error-dark: #DC2626;
|
||||
--color-warning: #F59E0B;
|
||||
/* Stati */
|
||||
--color-success: #22C55E;
|
||||
--color-success-dark: #16A34A;
|
||||
--color-error: #EF4444;
|
||||
--color-error-dark: #DC2626;
|
||||
--color-warning: #F59E0B;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -31,25 +31,25 @@
|
||||
============================================ */
|
||||
|
||||
l:root {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -58,14 +58,14 @@ html, body {
|
||||
|
||||
button,
|
||||
.touch-target {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 16px; /* Previene zoom su iOS */
|
||||
font-size: 16px; /* Previene zoom su iOS */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -73,70 +73,70 @@ input {
|
||||
============================================ */
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
||||
}
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
25%, 75% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
0%, 50%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
25%, 75% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-error {
|
||||
animation: pulse-error 1s ease-in-out infinite;
|
||||
animation: pulse-error 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1.5s step-start infinite;
|
||||
animation: blink 1.5s step-start infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -144,11 +144,11 @@ input {
|
||||
============================================ */
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {StrictMode} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import {DebugScreen} from './screens'
|
||||
|
||||
function DebugWrapper() {
|
||||
return <DebugScreen onBack={() => window.location.href = '/'}/>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App/>}/>
|
||||
<Route path="/debug" element={<DebugWrapper/>}/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -3,211 +3,293 @@
|
||||
* Schermata principale del varco attivo
|
||||
*/
|
||||
|
||||
import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components';
|
||||
import type { RoomInfo, User, RFIDScannerState } from '../types';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Button, CountdownTimer, Logo, NumLockBanner, RFIDStatus, UserCard} from '../components';
|
||||
import type {RFIDScannerState, RoomInfo, User} from '../types';
|
||||
|
||||
// Timeout per badge non trovato (30 secondi)
|
||||
const NOT_FOUND_TIMEOUT_SECONDS = 30;
|
||||
|
||||
interface ActiveGateScreenProps {
|
||||
roomInfo: RoomInfo;
|
||||
rfidState: RFIDScannerState;
|
||||
rfidBuffer: string;
|
||||
currentUser: User | null;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onCancelUser: () => void;
|
||||
onLogout: () => void;
|
||||
userTimeoutSeconds?: number;
|
||||
onUserTimeout: () => void;
|
||||
roomInfo: RoomInfo;
|
||||
rfidState: RFIDScannerState;
|
||||
rfidBuffer: string;
|
||||
currentUser: User | null;
|
||||
notFoundBadge: string | null;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onCancelUser: () => void;
|
||||
onLogout: () => void;
|
||||
userTimeoutSeconds?: number;
|
||||
onUserTimeout: () => void;
|
||||
showValidatorBadgeNotice?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveGateScreen({
|
||||
roomInfo,
|
||||
rfidState,
|
||||
rfidBuffer,
|
||||
currentUser,
|
||||
loading,
|
||||
error,
|
||||
onCancelUser,
|
||||
onLogout,
|
||||
userTimeoutSeconds = 60,
|
||||
onUserTimeout,
|
||||
}: ActiveGateScreenProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="p-4 md:p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||
<Logo size="md" />
|
||||
<div className="flex items-center gap-4 md:gap-8">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLogout}
|
||||
>
|
||||
Esci
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
roomInfo,
|
||||
rfidState,
|
||||
rfidBuffer,
|
||||
currentUser,
|
||||
notFoundBadge,
|
||||
loading,
|
||||
error,
|
||||
onCancelUser,
|
||||
onLogout,
|
||||
userTimeoutSeconds = 60,
|
||||
onUserTimeout,
|
||||
showValidatorBadgeNotice = false,
|
||||
}: ActiveGateScreenProps) {
|
||||
// Timer per countdown badge non trovato
|
||||
const [notFoundCountdown, setNotFoundCountdown] = useState(NOT_FOUND_TIMEOUT_SECONDS);
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-8">
|
||||
{loading ? (
|
||||
// Loading state
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-focolare-blue"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl text-gray-600">Caricamento dati...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
// Error state
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-slide-up text-center max-w-md">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl text-error font-semibold mb-4">{error}</p>
|
||||
<Button variant="secondary" onClick={onCancelUser}>
|
||||
Chiudi
|
||||
</Button>
|
||||
</div>
|
||||
) : currentUser ? (
|
||||
// User found - Decision screen
|
||||
<div className="w-full max-w-4xl animate-slide-up">
|
||||
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
|
||||
{/* Timer bar */}
|
||||
<div className="mb-6">
|
||||
<CountdownTimer
|
||||
seconds={userTimeoutSeconds}
|
||||
onExpire={onUserTimeout}
|
||||
warningThreshold={20}
|
||||
dangerThreshold={10}
|
||||
/>
|
||||
</div>
|
||||
// Reset countdown quando cambia notFoundBadge
|
||||
useEffect(() => {
|
||||
if (notFoundBadge) {
|
||||
setNotFoundCountdown(NOT_FOUND_TIMEOUT_SECONDS);
|
||||
const interval = setInterval(() => {
|
||||
setNotFoundCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
onUserTimeout();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [notFoundBadge, onUserTimeout]);
|
||||
|
||||
{/* User Card */}
|
||||
<UserCard user={currentUser} size="full" />
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header
|
||||
className="p-4 md:p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||
<Logo size="md"/>
|
||||
<div className="flex items-center gap-4 md:gap-8">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLogout}
|
||||
>
|
||||
Esci
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Action Hint */}
|
||||
<div className="mt-8 text-center">
|
||||
{currentUser.ammesso ? (
|
||||
<div className="py-6 px-8 bg-success/10 rounded-2xl border-2 border-success/30">
|
||||
<p className="text-xl text-success font-semibold mb-2">
|
||||
✓ Utente ammesso all'ingresso
|
||||
</p>
|
||||
<p className="text-lg text-gray-600">
|
||||
Passa il <span className="font-bold text-focolare-blue">badge VALIDATORE</span> per confermare l'accesso
|
||||
</p>
|
||||
</div>
|
||||
{/* Notifica Badge Validatore Ignorato */}
|
||||
{showValidatorBadgeNotice && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in">
|
||||
<div className="bg-amber-100 border border-amber-400 text-amber-800 px-6 py-3 rounded-xl shadow-lg">
|
||||
<p className="font-semibold">Badge validatore rilevato</p>
|
||||
<p className="text-sm">Se il validatore è cambiato, esci e rilogga con il nuovo badge.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-8">
|
||||
{loading ? (
|
||||
// Loading state
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-focolare-blue"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl text-gray-600">Caricamento dati...</p>
|
||||
</div>
|
||||
) : notFoundBadge ? (
|
||||
// Badge non trovato
|
||||
<div className="glass rounded-3xl p-12 md:p-16 shadow-xl text-center max-w-2xl w-full">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-error/10 mb-8">
|
||||
<svg
|
||||
className="h-16 w-16 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-2xl text-gray-700 mb-2">Utente con badge:</p>
|
||||
<p className="text-3xl font-bold text-error font-mono mb-4">{notFoundBadge}</p>
|
||||
<p className="text-2xl text-gray-700">non trovato nel sistema</p>
|
||||
|
||||
{/* Footer con countdown */}
|
||||
<div className="mt-10 pt-6 border-t border-gray-200">
|
||||
<p className="text-gray-500">
|
||||
Ritorno all'attesa in <span
|
||||
className="font-bold text-focolare-blue">{notFoundCountdown}</span> secondi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
// Error state
|
||||
<div className="glass rounded-3xl p-12 shadow-xl text-center max-w-md">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl text-error font-semibold mb-4">{error}</p>
|
||||
<Button variant="secondary" onClick={onCancelUser}>
|
||||
Chiudi
|
||||
</Button>
|
||||
</div>
|
||||
) : currentUser ? (
|
||||
// User found - Decision screen
|
||||
<div className="w-full max-w-5xl animate-slide-up">
|
||||
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
|
||||
{/* Timer bar */}
|
||||
<div className="mb-6">
|
||||
<CountdownTimer
|
||||
seconds={userTimeoutSeconds}
|
||||
onExpire={onUserTimeout}
|
||||
warningThreshold={20}
|
||||
dangerThreshold={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Card */}
|
||||
<UserCard user={currentUser} size="full"/>
|
||||
|
||||
{/* Action Hint */}
|
||||
<div className="mt-8 text-center">
|
||||
{currentUser.ammesso ? (
|
||||
<div className="py-6 px-8 bg-success/10 rounded-2xl border-2 border-success/30">
|
||||
<p className="text-xl text-success font-semibold mb-2">
|
||||
✓ Utente ammesso all'ingresso
|
||||
</p>
|
||||
<p className="text-lg text-gray-600">
|
||||
Passa il <span
|
||||
className="font-bold text-focolare-blue">badge VALIDATORE</span> per
|
||||
confermare l'accesso
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 px-8 bg-error/10 rounded-2xl border-2 border-error/30">
|
||||
<p className="text-xl text-error font-bold mb-2 animate-blink">
|
||||
⚠️ ACCESSO NON CONSENTITO
|
||||
</p>
|
||||
<p className="text-lg text-gray-600">
|
||||
Questo utente non è autorizzato ad entrare
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onCancelUser}
|
||||
>
|
||||
✕ Annulla
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 px-8 bg-error/10 rounded-2xl border-2 border-error/30">
|
||||
<p className="text-xl text-error font-bold mb-2 animate-blink">
|
||||
⚠️ ACCESSO NON CONSENTITO
|
||||
</p>
|
||||
<p className="text-lg text-gray-600">
|
||||
Questo utente non è autorizzato ad entrare
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
// Idle - Waiting for participant
|
||||
<div
|
||||
className="glass rounded-3xl p-12 md:p-16 shadow-xl animate-fade-in text-center max-w-3xl w-full">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-40 h-40 rounded-full bg-focolare-blue/10 mb-10">
|
||||
<svg
|
||||
className="w-20 h-20 text-focolare-blue"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onCancelUser}
|
||||
>
|
||||
✕ Annulla
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Idle - Waiting for participant
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center max-w-xl">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-focolare-blue/10 mb-8">
|
||||
<svg
|
||||
className="w-16 h-16 text-focolare-blue"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-focolare-blue mb-6">
|
||||
Varco Attivo
|
||||
</h1>
|
||||
|
||||
<h1 className="text-4xl font-bold text-focolare-blue mb-4">
|
||||
Varco Attivo
|
||||
</h1>
|
||||
<p className="text-3xl text-gray-600 mb-10">
|
||||
In attesa del partecipante...
|
||||
</p>
|
||||
|
||||
<p className="text-2xl text-gray-600 mb-8">
|
||||
In attesa del partecipante...
|
||||
</p>
|
||||
|
||||
<div className="py-8 px-6 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-focolare-orange animate-pulse"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||
</svg>
|
||||
<span className="text-2xl text-focolare-orange font-medium">
|
||||
<div
|
||||
className="py-10 px-8 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-focolare-orange animate-pulse"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||
</svg>
|
||||
<span className="text-3xl text-focolare-orange font-medium">
|
||||
Passa il badge
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with RFID Status */}
|
||||
<footer className="p-4 border-t bg-white/50 flex items-center justify-between">
|
||||
<RFIDStatus state={rfidState} buffer={rfidBuffer} />
|
||||
<span className="text-sm text-gray-400">
|
||||
{/* Banner NumLock per desktop */}
|
||||
<NumLockBanner className="mt-8"/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer with RFID Status */}
|
||||
<footer className="p-4 border-t bg-white/50 flex items-center justify-between">
|
||||
<RFIDStatus state={rfidState} buffer={rfidBuffer}/>
|
||||
<span className="text-sm text-gray-400">
|
||||
Varco attivo • {new Date().toLocaleTimeString('it-IT')}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActiveGateScreen;
|
||||
|
||||
208
frontend/src/screens/DebugScreen.tsx
Normal file
208
frontend/src/screens/DebugScreen.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Debug Screen - Focolari Voting System
|
||||
*
|
||||
* Pagina diagnostica per debug del lettore RFID.
|
||||
* Mostra in tempo reale gli eventi tastiera e lo stato dello scanner.
|
||||
*/
|
||||
|
||||
import {useRFIDScanner, VALID_PATTERNS} from '../hooks/useRFIDScanner';
|
||||
import {Button, Logo} from '../components';
|
||||
|
||||
interface DebugScreenProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function DebugScreen({onBack}: DebugScreenProps) {
|
||||
const {
|
||||
state,
|
||||
buffer,
|
||||
lastScan,
|
||||
activePattern,
|
||||
keyLog,
|
||||
reset,
|
||||
} = useRFIDScanner({
|
||||
onScan: (code) => {
|
||||
console.log('[DEBUG] Scan received:', code);
|
||||
},
|
||||
onTimeout: () => {
|
||||
console.log('[DEBUG] Timeout occurred');
|
||||
},
|
||||
});
|
||||
|
||||
const formatTimestamp = (ts: number) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString('it-IT', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-white p-4 md:p-6">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Logo size="sm"/>
|
||||
<h1 className="text-2xl font-bold text-white">Debug RFID</h1>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
← Torna all'app
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Scanner Status */}
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-focolare-orange">
|
||||
📡 Stato Scanner
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* State */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
|
||||
<span className="text-slate-300">Stato</span>
|
||||
<span className={`px-3 py-1 rounded-full font-mono font-bold ${
|
||||
state === 'scanning'
|
||||
? 'bg-focolare-orange text-white animate-pulse'
|
||||
: 'bg-slate-600 text-slate-300'
|
||||
}`}>
|
||||
{state.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active Pattern */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
|
||||
<span className="text-slate-300">Pattern Attivo</span>
|
||||
<span className="font-mono text-lg">
|
||||
{activePattern
|
||||
? `${activePattern.name} (${activePattern.start} → ${activePattern.end})`
|
||||
: '—'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buffer */}
|
||||
<div className="p-3 bg-slate-700 rounded-lg">
|
||||
<span className="text-slate-300 block mb-2">Buffer Corrente</span>
|
||||
<div className="font-mono text-2xl bg-slate-900 p-3 rounded min-h-[50px] break-all">
|
||||
{buffer || <span className="text-slate-500">(vuoto)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Scan */}
|
||||
<div className="p-3 bg-slate-700 rounded-lg">
|
||||
<span className="text-slate-300 block mb-2">Ultimo Codice Rilevato</span>
|
||||
{lastScan ? (
|
||||
<div className="bg-green-900/50 border border-green-500 rounded p-3">
|
||||
<p className="font-mono text-2xl text-green-400">{lastScan.code}</p>
|
||||
<p className="text-sm text-green-300 mt-1">
|
||||
{formatTimestamp(lastScan.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-900 p-3 rounded text-slate-500">
|
||||
Nessuna scansione
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<Button variant="secondary" onClick={reset} className="w-full">
|
||||
Reset Scanner
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Log */}
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-focolare-blue">
|
||||
⌨️ Log Tastiera (ultimi 20)
|
||||
</h2>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-3 gap-2 p-3 bg-slate-700 text-sm font-semibold">
|
||||
<span>Ora</span>
|
||||
<span>Key</span>
|
||||
<span>Code</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{keyLog.length === 0 ? (
|
||||
<div className="p-4 text-slate-500 text-center">
|
||||
Premi un tasto per vedere i log...
|
||||
</div>
|
||||
) : (
|
||||
keyLog.map((entry, index) => {
|
||||
const isStartSentinel = VALID_PATTERNS.some(p => p.start === entry.key);
|
||||
const isEndSentinel = VALID_PATTERNS.some(p => p.end === entry.key);
|
||||
|
||||
let rowClass = 'bg-slate-900';
|
||||
if (isStartSentinel) rowClass = 'bg-green-900/30';
|
||||
if (isEndSentinel) rowClass = 'bg-blue-900/30';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${entry.timestamp}-${index}`}
|
||||
className={`grid grid-cols-3 gap-2 p-2 border-b border-slate-700 text-sm ${rowClass}`}
|
||||
>
|
||||
<span className="text-slate-400 font-mono text-xs">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`font-mono ${
|
||||
isStartSentinel ? 'text-green-400 font-bold' :
|
||||
isEndSentinel ? 'text-blue-400 font-bold' :
|
||||
'text-white'
|
||||
}`}>
|
||||
{entry.key === ' ' ? '(space)' :
|
||||
entry.key === 'Enter' ? '↵ Enter' :
|
||||
entry.key}
|
||||
</span>
|
||||
<span className="text-slate-400 font-mono text-xs">
|
||||
{entry.code}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern Reference */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold mb-4 text-focolare-yellow">
|
||||
📋 Pattern Supportati
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{VALID_PATTERNS.map((pattern) => (
|
||||
<div
|
||||
key={pattern.name}
|
||||
className="bg-slate-700 rounded-lg p-4 text-center"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-2">{pattern.name}</h3>
|
||||
<div className="flex items-center justify-center gap-4 text-3xl font-mono">
|
||||
<span className="text-green-400">{pattern.start}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
<span className="text-blue-400">{pattern.end}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
Esempio: {pattern.start}123456{pattern.end}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-400">
|
||||
💡 <strong>Nota:</strong> Il lettore RFID invia anche Enter (↵) dopo l'ultimo carattere.
|
||||
L'hook lo gestisce automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DebugScreen;
|
||||
@@ -3,70 +3,71 @@
|
||||
* Modal per errori
|
||||
*/
|
||||
|
||||
import { Modal, Button } from '../components';
|
||||
import {Button, Modal} from '../components';
|
||||
|
||||
interface ErrorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Errore',
|
||||
message
|
||||
}: ErrorModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
variant="error"
|
||||
fullscreen
|
||||
>
|
||||
<div className="text-center text-white p-8 max-w-2xl">
|
||||
{/* Error Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-error">
|
||||
<svg
|
||||
className="w-20 h-20 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 animate-slide-up">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Error Message */}
|
||||
<p className="text-2xl md:text-3xl opacity-90 mb-12 animate-fade-in">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
className="bg-white text-error hover:bg-gray-100"
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Errore',
|
||||
message
|
||||
}: ErrorModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
variant="error"
|
||||
fullscreen
|
||||
>
|
||||
Chiudi
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
<div className="text-center text-white p-8 max-w-2xl">
|
||||
{/* Error Icon */}
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-error">
|
||||
<svg
|
||||
className="w-20 h-20 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 animate-slide-up">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Error Message */}
|
||||
<p className="text-2xl md:text-3xl opacity-90 mb-12 animate-fade-in">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
className="bg-white text-error hover:bg-gray-100"
|
||||
>
|
||||
Chiudi
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorModal;
|
||||
|
||||
@@ -2,89 +2,153 @@
|
||||
* Loading Screen - Focolari Voting System
|
||||
*/
|
||||
|
||||
import { Logo } from '../components';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Logo} from '../components';
|
||||
import {checkServerHealth} from '../services/api';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
message?: string;
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
message?: string;
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function LoadingScreen({
|
||||
message = 'Connessione al server...',
|
||||
error,
|
||||
onRetry
|
||||
}: LoadingScreenProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-br from-focolare-blue/5 to-focolare-blue/20">
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in max-w-lg w-full text-center">
|
||||
<Logo size="lg" showText={false} />
|
||||
message = 'Connessione al server...',
|
||||
error,
|
||||
onRetry
|
||||
}: LoadingScreenProps) {
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking');
|
||||
|
||||
<h1 className="mt-6 text-3xl font-bold text-focolare-blue">
|
||||
Focolari Voting System
|
||||
</h1>
|
||||
// Ping automatico quando c'è un errore
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
|
||||
{!error ? (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10">
|
||||
<svg
|
||||
className="animate-spin h-8 w-8 text-focolare-blue"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
const checkServer = async () => {
|
||||
setServerStatus('checking');
|
||||
const isOnline = await checkServerHealth();
|
||||
setServerStatus(isOnline ? 'online' : 'offline');
|
||||
};
|
||||
|
||||
checkServer();
|
||||
|
||||
// Ripeti il ping ogni 3 secondi
|
||||
const interval = setInterval(checkServer, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [error]);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (!onRetry) return;
|
||||
|
||||
setIsRetrying(true);
|
||||
const isOnline = await checkServerHealth();
|
||||
|
||||
if (isOnline) {
|
||||
onRetry();
|
||||
} else {
|
||||
setServerStatus('offline');
|
||||
}
|
||||
setIsRetrying(false);
|
||||
}, [onRetry]);
|
||||
|
||||
// Se il server torna online, riprova automaticamente
|
||||
useEffect(() => {
|
||||
if (serverStatus === 'online' && error && onRetry) {
|
||||
console.log('[FLOW] Server tornato online, ricarico...');
|
||||
onRetry();
|
||||
}
|
||||
}, [serverStatus, error, onRetry]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-br from-focolare-blue/5 to-focolare-blue/20">
|
||||
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in max-w-lg w-full text-center">
|
||||
<Logo size="lg" showText={false}/>
|
||||
|
||||
<h1 className="mt-6 text-3xl font-bold text-focolare-blue">
|
||||
Focolari Voting System
|
||||
</h1>
|
||||
|
||||
{!error ? (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10">
|
||||
<svg
|
||||
className="animate-spin h-8 w-8 text-focolare-blue"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 text-lg">{message}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/10">
|
||||
<svg
|
||||
className="h-8 w-8 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
|
||||
|
||||
{/* Server status indicator */}
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
serverStatus === 'checking' ? 'bg-yellow-500 animate-pulse' :
|
||||
serverStatus === 'online' ? 'bg-green-500' :
|
||||
'bg-red-500'
|
||||
}`}/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{serverStatus === 'checking' && 'Verifica connessione...'}
|
||||
{serverStatus === 'online' && 'Server raggiungibile - riconnessione...'}
|
||||
{serverStatus === 'offline' && 'Server non raggiungibile'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
|
||||
font-semibold hover:bg-focolare-blue/90 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRetrying ? 'Verifica in corso...' : 'Riprova'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 text-lg">{message}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/10">
|
||||
<svg
|
||||
className="h-8 w-8 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
|
||||
font-semibold hover:bg-focolare-blue-dark transition-colors"
|
||||
>
|
||||
Riprova
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingScreen;
|
||||
|
||||
@@ -1,88 +1,78 @@
|
||||
/**
|
||||
* Success Modal - Focolari Voting System
|
||||
* Modal fullscreen per conferma ingresso
|
||||
* Modal fullscreen per conferma ingresso con carosello multilingua
|
||||
*/
|
||||
|
||||
import { Modal } from '../components';
|
||||
import {Modal, WelcomeCarousel} from '../components';
|
||||
|
||||
interface SuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
welcomeMessage: string;
|
||||
userName?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export function SuccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
welcomeMessage,
|
||||
userName
|
||||
}: SuccessModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
variant="success"
|
||||
autoCloseMs={5000}
|
||||
fullscreen
|
||||
>
|
||||
<div className="text-center text-white p-8">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow">
|
||||
<svg
|
||||
className="w-20 h-20 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
isOpen,
|
||||
onClose,
|
||||
userName
|
||||
}: SuccessModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
variant="success"
|
||||
autoCloseMs={5000}
|
||||
fullscreen
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow">
|
||||
<svg
|
||||
className="w-20 h-20 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Name */}
|
||||
{userName && (
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-4 animate-slide-up">
|
||||
{userName}
|
||||
</h2>
|
||||
)}
|
||||
{/* Carosello Messaggi Benvenuto */}
|
||||
<WelcomeCarousel userName={userName}/>
|
||||
|
||||
{/* Welcome Message */}
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-8 animate-slide-up">
|
||||
{welcomeMessage}
|
||||
</h1>
|
||||
{/* Sub text */}
|
||||
<p className="text-2xl md:text-3xl text-white/80 mt-8 animate-fade-in">
|
||||
Ingresso registrato con successo
|
||||
</p>
|
||||
|
||||
{/* Sub text */}
|
||||
<p className="text-2xl md:text-3xl opacity-80 animate-fade-in">
|
||||
Ingresso registrato con successo
|
||||
</p>
|
||||
|
||||
{/* Auto-close indicator */}
|
||||
<div className="mt-12">
|
||||
<div className="w-64 h-2 bg-white/30 rounded-full mx-auto overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white rounded-full"
|
||||
style={{
|
||||
animation: 'shrink 5s linear forwards',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<style>{`
|
||||
{/* Auto-close indicator */}
|
||||
<div className="mt-12 w-full max-w-md">
|
||||
<div className="w-full h-2 bg-white/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white rounded-full"
|
||||
style={{
|
||||
animation: 'shrink 5s linear forwards',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessModal;
|
||||
|
||||
@@ -2,178 +2,193 @@
|
||||
* Validator Login Screen - Focolari Voting System
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Logo, Button, Input, RFIDStatus } from '../components';
|
||||
import type { RoomInfo, RFIDScannerState } from '../types';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {Button, Input, Logo, NumLockBanner, RFIDStatus} from '../components';
|
||||
import type {RFIDScannerState, RoomInfo} from '../types';
|
||||
|
||||
interface ValidatorLoginScreenProps {
|
||||
roomInfo: RoomInfo;
|
||||
rfidState: RFIDScannerState;
|
||||
rfidBuffer: string;
|
||||
validatorBadge: string | null;
|
||||
onPasswordSubmit: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
roomInfo: RoomInfo;
|
||||
rfidState: RFIDScannerState;
|
||||
rfidBuffer: string;
|
||||
validatorBadge: string | null;
|
||||
onPasswordSubmit: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ValidatorLoginScreen({
|
||||
roomInfo,
|
||||
rfidState,
|
||||
rfidBuffer,
|
||||
validatorBadge,
|
||||
onPasswordSubmit,
|
||||
onCancel,
|
||||
error,
|
||||
loading = false,
|
||||
}: ValidatorLoginScreenProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
roomInfo,
|
||||
rfidState,
|
||||
rfidBuffer,
|
||||
validatorBadge,
|
||||
onPasswordSubmit,
|
||||
onCancel,
|
||||
error,
|
||||
loading = false,
|
||||
}: ValidatorLoginScreenProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input quando appare il form password
|
||||
useEffect(() => {
|
||||
if (validatorBadge && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [validatorBadge]);
|
||||
// Focus input quando appare il form password
|
||||
useEffect(() => {
|
||||
if (validatorBadge && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [validatorBadge]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password.trim()) {
|
||||
onPasswordSubmit(password);
|
||||
}
|
||||
};
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password.trim()) {
|
||||
onPasswordSubmit(password);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||
<Logo size="md" />
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="glass rounded-3xl p-10 shadow-xl max-w-xl w-full animate-slide-up">
|
||||
{!validatorBadge ? (
|
||||
// Attesa badge validatore
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-focolare-blue/10 mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-focolare-blue"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||
<Logo size="md"/>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-4">
|
||||
Accesso Varco
|
||||
</h1>
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="glass rounded-3xl p-10 shadow-xl max-w-xl w-full animate-slide-up">
|
||||
{!validatorBadge ? (
|
||||
// Attesa badge validatore
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-focolare-blue/10 mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-focolare-blue"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Passa il badge del <span className="font-semibold text-focolare-blue">Validatore</span> per iniziare
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-4">
|
||||
Accesso Varco
|
||||
</h1>
|
||||
|
||||
<div className="py-8 px-6 bg-focolare-blue/5 rounded-2xl border-2 border-dashed border-focolare-blue/30">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-focolare-blue animate-pulse"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||
</svg>
|
||||
<span className="text-xl text-focolare-blue font-medium">
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Passa il badge del <span
|
||||
className="font-semibold text-focolare-blue">Validatore</span> per iniziare
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="py-8 px-6 bg-focolare-blue/5 rounded-2xl border-2 border-dashed border-focolare-blue/30">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-focolare-blue animate-pulse"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||
</svg>
|
||||
<span className="text-xl text-focolare-blue font-medium">
|
||||
In attesa del badge...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Form password
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-success"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
Badge Riconosciuto
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Badge: <span className="font-mono font-semibold">{validatorBadge}</span>
|
||||
</p>
|
||||
</div>
|
||||
{/* Banner NumLock per desktop */}
|
||||
<NumLockBanner className="mt-6"/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
label="Password Validatore"
|
||||
placeholder="Inserisci la password..."
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
{/* Messaggio errore */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Form password
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-success"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
Conferma
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
Badge Riconosciuto
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Badge: <span className="font-mono font-semibold">{validatorBadge}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
label="Password Validatore"
|
||||
placeholder="Inserisci la password..."
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
Conferma
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer with RFID Status */}
|
||||
<footer className="p-4 border-t bg-white/50 flex items-center justify-center">
|
||||
<RFIDStatus state={rfidState} buffer={rfidBuffer}/>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer with RFID Status */}
|
||||
<footer className="p-4 border-t bg-white/50 flex items-center justify-center">
|
||||
<RFIDStatus state={rfidState} buffer={rfidBuffer} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidatorLoginScreen;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Screens barrel export
|
||||
export { LoadingScreen } from './LoadingScreen';
|
||||
export { ValidatorLoginScreen } from './ValidatorLoginScreen';
|
||||
export { ActiveGateScreen } from './ActiveGateScreen';
|
||||
export { SuccessModal } from './SuccessModal';
|
||||
export { ErrorModal } from './ErrorModal';
|
||||
export {LoadingScreen} from './LoadingScreen';
|
||||
export {ValidatorLoginScreen} from './ValidatorLoginScreen';
|
||||
export {ActiveGateScreen} from './ActiveGateScreen';
|
||||
export {SuccessModal} from './SuccessModal';
|
||||
export {ErrorModal} from './ErrorModal';
|
||||
export {DebugScreen} from './DebugScreen';
|
||||
|
||||
@@ -2,67 +2,84 @@
|
||||
* Focolari Voting System - API Service
|
||||
*/
|
||||
|
||||
import type {
|
||||
RoomInfo,
|
||||
User,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
EntryRequest,
|
||||
EntryResponse
|
||||
} from '../types';
|
||||
import type {EntryRequest, EntryResponse, LoginRequest, LoginResponse, RoomInfo, User} from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
// Path relativi: funziona sia in dev (proxy Vite) che in produzione (stesso server)
|
||||
const API_BASE_URL = '';
|
||||
|
||||
// ============================================
|
||||
// LOGGING
|
||||
// ============================================
|
||||
|
||||
const log = (message: string, ...args: unknown[]) => {
|
||||
console.log(`[API] ${message}`, ...args);
|
||||
};
|
||||
|
||||
const logError = (message: string, ...args: unknown[]) => {
|
||||
console.error(`[API] ${message}`, ...args);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class for API errors
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number,
|
||||
public detail?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
public statusCode: number;
|
||||
public detail?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
statusCode: number,
|
||||
detail?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.statusCode = statusCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch wrapper with error handling
|
||||
*/
|
||||
async function apiFetch<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
log(`Fetching ${options?.method || 'GET'} ${endpoint}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.detail || `HTTP Error ${response.status}`,
|
||||
response.status,
|
||||
errorData.detail
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
logError(`Error ${response.status}: ${errorData.detail || 'Unknown error'}`);
|
||||
throw new ApiError(
|
||||
errorData.detail || `HTTP Error ${response.status}`,
|
||||
response.status,
|
||||
errorData.detail
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log(`Response OK from ${endpoint}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
logError('Connection error:', error);
|
||||
throw new ApiError(
|
||||
'Errore di connessione al server',
|
||||
0,
|
||||
'Verifica che il server sia attivo'
|
||||
);
|
||||
}
|
||||
throw new ApiError(
|
||||
'Errore di connessione al server',
|
||||
0,
|
||||
'Verifica che il server sia attivo'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -74,7 +91,7 @@ async function apiFetch<T>(
|
||||
* Ottiene le informazioni sulla sala e la riunione
|
||||
*/
|
||||
export async function getRoomInfo(): Promise<RoomInfo> {
|
||||
return apiFetch<RoomInfo>('/info-room');
|
||||
return apiFetch<RoomInfo>('/info-room');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,14 +99,15 @@ export async function getRoomInfo(): Promise<RoomInfo> {
|
||||
* Autentica il validatore con badge e password
|
||||
*/
|
||||
export async function loginValidator(
|
||||
badge: string,
|
||||
password: string
|
||||
badge: string,
|
||||
password: string
|
||||
): Promise<LoginResponse> {
|
||||
const payload: LoginRequest = { badge, password };
|
||||
return apiFetch<LoginResponse>('/login-validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
log(`Login attempt for badge: ${badge}`);
|
||||
const payload: LoginRequest = {badge, password};
|
||||
return apiFetch<LoginResponse>('/login-validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +115,8 @@ export async function loginValidator(
|
||||
* Ottiene i dati anagrafici di un utente tramite badge
|
||||
*/
|
||||
export async function getUserByBadge(badgeCode: string): Promise<User> {
|
||||
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
|
||||
log(`Fetching anagrafica for badge: ${badgeCode}`);
|
||||
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,17 +124,18 @@ export async function getUserByBadge(badgeCode: string): Promise<User> {
|
||||
* Registra l'ingresso di un utente
|
||||
*/
|
||||
export async function requestEntry(
|
||||
userBadge: string,
|
||||
validatorPassword: string
|
||||
userBadge: string,
|
||||
validatorPassword: string
|
||||
): Promise<EntryResponse> {
|
||||
const payload: EntryRequest = {
|
||||
user_badge: userBadge,
|
||||
validator_password: validatorPassword,
|
||||
};
|
||||
return apiFetch<EntryResponse>('/entry-request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
log(`Entry request for badge: ${userBadge}`);
|
||||
const payload: EntryRequest = {
|
||||
user_badge: userBadge,
|
||||
validator_password: validatorPassword,
|
||||
};
|
||||
return apiFetch<EntryResponse>('/entry-request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -126,10 +146,10 @@ export async function requestEntry(
|
||||
* Check if the API server is reachable
|
||||
*/
|
||||
export async function checkServerHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,103 @@
|
||||
/**
|
||||
}
|
||||
variant?: 'success' | 'error' | 'info';
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
isOpen: boolean;
|
||||
export interface ModalProps {
|
||||
|
||||
}
|
||||
paused?: boolean;
|
||||
onExpire: () => void;
|
||||
seconds: number;
|
||||
export interface TimerProps {
|
||||
|
||||
}
|
||||
showWarning?: boolean;
|
||||
user: User;
|
||||
export interface UserCardProps {
|
||||
|
||||
// ============================================
|
||||
// Component Props Types
|
||||
// ============================================
|
||||
|
||||
}
|
||||
timestamp: number;
|
||||
code: string;
|
||||
export interface RFIDScanResult {
|
||||
|
||||
export type RFIDScannerState = 'idle' | 'scanning';
|
||||
|
||||
// ============================================
|
||||
// RFID Scanner Types
|
||||
// ============================================
|
||||
|
||||
}
|
||||
expiresAt: number;
|
||||
loginTime: number;
|
||||
token: string;
|
||||
badge: string;
|
||||
export interface ValidatorSession {
|
||||
|
||||
| 'entry-error';
|
||||
| 'entry-success'
|
||||
| 'showing-user'
|
||||
| 'gate-active'
|
||||
| 'validator-password'
|
||||
| 'waiting-validator'
|
||||
| 'loading'
|
||||
export type AppState =
|
||||
|
||||
// ============================================
|
||||
// Application State Types
|
||||
// ============================================
|
||||
|
||||
}
|
||||
validator_password: string;
|
||||
user_badge: string;
|
||||
export interface EntryRequest {
|
||||
|
||||
}
|
||||
password: string;
|
||||
badge: string;
|
||||
export interface LoginRequest {
|
||||
|
||||
// ============================================
|
||||
// Request Types
|
||||
// ============================================
|
||||
|
||||
}
|
||||
welcome_message?: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
export interface EntryResponse {
|
||||
|
||||
}
|
||||
token?: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
export interface LoginResponse {
|
||||
|
||||
}
|
||||
warning?: string;
|
||||
ammesso: boolean;
|
||||
ruolo: 'Tecnico' | 'Votante' | 'Ospite';
|
||||
url_foto: string;
|
||||
cognome: string;
|
||||
nome: string;
|
||||
badge_code: string;
|
||||
export interface User {
|
||||
|
||||
}
|
||||
meeting_id: string;
|
||||
room_name: string;
|
||||
export interface RoomInfo {
|
||||
* Focolari Voting System - TypeScript Types
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
*/
|
||||
* Focolari Voting System - TypeScript Types
|
||||
export interface RoomInfo {
|
||||
room_name: string;
|
||||
meeting_id: string;
|
||||
server_start_time: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
badge_code: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
url_foto: string;
|
||||
ruolo: 'Tecnico' | 'Votante' | 'Ospite';
|
||||
ammesso: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface EntryResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Request Types
|
||||
// ============================================
|
||||
|
||||
export interface LoginRequest {
|
||||
badge: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface EntryRequest {
|
||||
user_badge: string;
|
||||
validator_password: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Application State Types
|
||||
// ============================================
|
||||
|
||||
export type AppState =
|
||||
| 'loading'
|
||||
| 'waiting-validator'
|
||||
| 'validator-password'
|
||||
| 'gate-active'
|
||||
| 'showing-user'
|
||||
| 'entry-success'
|
||||
| 'entry-error';
|
||||
|
||||
export interface ValidatorSession {
|
||||
badge: string;
|
||||
password: string;
|
||||
token: string;
|
||||
loginTime: number;
|
||||
expiresAt: number;
|
||||
serverStartTime: number; // Per invalidare se il server riparte
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RFID Scanner Types
|
||||
// ============================================
|
||||
|
||||
export type RFIDScannerState = 'idle' | 'scanning';
|
||||
|
||||
export interface RFIDScanResult {
|
||||
code: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Component Props Types
|
||||
// ============================================
|
||||
|
||||
export interface UserCardProps {
|
||||
user: User;
|
||||
showWarning?: boolean;
|
||||
}
|
||||
|
||||
export interface TimerProps {
|
||||
seconds: number;
|
||||
onExpire: () => void;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
variant?: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user