feat: setup iniziale sistema controllo accessi Focolari

Struttura progetto:
- Backend mock Python (FastAPI) con API per gestione varchi
- Frontend React + TypeScript + Vite + Tailwind CSS
- Documentazione e piani di sviluppo

Backend (backend-mock/):
- API REST: /info-room, /login-validate, /anagrafica, /entry-request
- Dati mock: 7 utenti, validatore (999999/focolari)
- CORS abilitato, docs OpenAPI automatiche
- Configurazione pipenv per ambiente virtuale

Frontend (frontend/):
- State machine completa per flusso accesso varco
- Hook useRFIDScanner per lettura badge (pattern singolo)
- Componenti UI: Logo, Button, Input, Modal, UserCard, Timer
- Schermate: Loading, Login, ActiveGate, Success/Error Modal
- Design system con colori Focolari
- Ottimizzato per tablet touch

Documentazione (ai-prompts/):
- Welcome guide per futuri agenti
- Piano sviluppo backend e frontend con checklist

DA COMPLETARE:
- Hook RFID multi-pattern (US/IT/altri layout tastiera)
- Pagina /debug per diagnostica in loco
- Logging console strutturato
This commit is contained in:
2026-01-17 18:20:55 +01:00
commit 21b509c6ba
40 changed files with 7453 additions and 0 deletions

49
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# ================================================
# Focolari Voting System - Frontend .gitignore
# ================================================
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
build/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Local env files
*.local
.env
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
# Cache
.cache/
.eslintcache

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3924
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

376
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,376 @@
/**
* Focolari Voting System - Main Application
* 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';
// 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);
// Modal states
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorModalMessage, setErrorModalMessage] = useState('');
// ============================================
// Session Management
// ============================================
const saveSession = useCallback((session: ValidatorSession) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
setValidatorSession(session);
}, []);
const clearSession = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setValidatorSession(null);
setPendingValidatorBadge(null);
setCurrentUser(null);
setAppState('waiting-validator');
}, []);
const loadSession = useCallback((): 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) {
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);
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) return;
setLoading(true);
setError(undefined);
try {
const response = await loginValidator(pendingValidatorBadge, password);
if (response.success) {
const session: ValidatorSession = {
badge: pendingValidatorBadge,
token: response.token || '',
loginTime: Date.now(),
expiresAt: Date.now() + SESSION_DURATION_MS,
};
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]);
// ============================================
// UI Handlers
// ============================================
const handleCancelPassword = useCallback(() => {
setPendingValidatorBadge(null);
setError(undefined);
setAppState('waiting-validator');
}, []);
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') {
return (
<LoadingScreen
message="Connessione al server..."
error={error}
onRetry={() => window.location.reload()}
/>
);
}
// 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,90 @@
/**
* Button Component - Focolari Voting System
*/
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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';
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 widthClass = fullWidth ? 'w-full' : '';
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">
<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"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Caricamento...
</span>
) : (
children
)}
</button>
);
}
export default Button;

View File

@@ -0,0 +1,101 @@
/**
* Countdown Timer Component - Focolari Voting System
*/
import { useState, useEffect, useCallback } 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;
}
export function CountdownTimer({
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')}`;
}, []);
useEffect(() => {
setRemaining(seconds);
}, [seconds]);
useEffect(() => {
if (paused || remaining <= 0) {
return;
}
const timer = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(timer);
onExpire();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [paused, remaining, onExpire]);
const getColorClass = (): string => {
if (remaining <= dangerThreshold) {
return 'text-error';
}
if (remaining <= warningThreshold) {
return 'text-warning';
}
return 'text-focolare-blue';
};
const getBarColorClass = (): string => {
if (remaining <= dangerThreshold) {
return 'bg-error';
}
if (remaining <= warningThreshold) {
return 'bg-warning';
}
return 'bg-focolare-blue';
};
const progressPercent = (remaining / seconds) * 100;
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>
);
}
export default CountdownTimer;

View File

@@ -0,0 +1,48 @@
/**
* Input Component - Focolari Voting System
*/
import { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
fullWidth?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, fullWidth = true, className = '', ...props }, ref) => {
const widthClass = fullWidth ? 'w-full' : '';
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>
);
}
);
Input.displayName = 'Input';
export default Input;

View File

@@ -0,0 +1,44 @@
/**
* Logo Component - Focolari Voting System
*/
import FocolareLogo from '../assets/FocolareMovLogo.jpg';
interface LogoProps {
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',
};
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">
<span className={`${textSizeClasses[size]} font-bold text-focolare-blue`}>
Movimento dei Focolari
</span>
<span className="text-sm text-gray-500">Sistema Votazioni</span>
</div>
)}
</div>
);
}
export default Logo;

View File

@@ -0,0 +1,80 @@
/**
* Modal Component - Focolari Voting System
*/
import { useEffect } from 'react';
interface ModalProps {
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;
}
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 variantClasses = {
success: 'bg-success',
error: 'bg-error',
info: 'bg-focolare-blue',
};
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>
);
}
export default Modal;

View File

@@ -0,0 +1,33 @@
/**
* RFID Status Indicator - Focolari Voting System
* Mostra lo stato del lettore RFID
*/
import type { RFIDScannerState } from '../types';
interface RFIDStatusProps {
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>
);
}
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>
);
}
export default RFIDStatus;

View File

@@ -0,0 +1,100 @@
/**
* User Card Component - Focolari Voting System
*/
import type { User } from '../types';
interface UserCardProps {
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',
};
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]}`}>
{user.ruolo}
</span>
</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);
}}
/>
<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">
<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'
}`}>
{user.ammesso ? '✓ AMMESSO' : '✗ NON AMMESSO'}
</span>
</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>
</div>
)}
</div>
);
}
export default UserCard;

View File

@@ -0,0 +1,8 @@
// 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';

View File

@@ -0,0 +1,219 @@
/**
* Focolari Voting System - RFID Scanner Hook
*
* 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?`
*
* L'hook funziona indipendentemente dal focus dell'applicazione.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { RFIDScannerState, RFIDScanResult } from '../types';
// Costanti
const START_SENTINEL = ';';
const END_SENTINEL = '?';
const TIMEOUT_MS = 3000; // 3 secondi di timeout
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;
}
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 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);
// Refs per mantenere i valori aggiornati nei callback
const bufferRef = useRef<string>('');
const stateRef = useRef<RFIDScannerState>('idle');
const timeoutRef = useRef<number | null>(null);
// Sync refs con state
useEffect(() => {
bufferRef.current = buffer;
}, [buffer]);
useEffect(() => {
stateRef.current = state;
}, [state]);
/**
* Pulisce il timeout attivo
*/
const clearScanTimeout = useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
/**
* Resetta lo scanner allo stato idle
*/
const reset = useCallback(() => {
clearScanTimeout();
setState('idle');
setBuffer('');
bufferRef.current = '';
stateRef.current = 'idle';
}, [clearScanTimeout]);
/**
* 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();
}
}
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');
if (preventDefaultOnScan) {
event.preventDefault();
}
setState('scanning');
setBuffer('');
bufferRef.current = '';
startTimeout();
onScanStart?.();
}
// Altrimenti ignora il tasto (comportamento normale)
return;
}
// STATO SCANNING: accumula i caratteri o termina
if (stateRef.current === 'scanning') {
if (preventDefaultOnScan) {
event.preventDefault();
}
if (key === END_SENTINEL) {
// Fine della scansione
clearScanTimeout();
const scannedCode = bufferRef.current.trim();
if (scannedCode.length > 0) {
console.log('[RFID Scanner] Codice scansionato:', scannedCode);
const result: RFIDScanResult = {
code: scannedCode,
timestamp: Date.now(),
};
setLastScan(result);
onScan(scannedCode);
} else {
console.warn('[RFID Scanner] Codice vuoto scartato');
}
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;
}
}
};
// 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;

154
frontend/src/index.css Normal file
View File

@@ -0,0 +1,154 @@
@import "tailwindcss";
/* ============================================
FOCOLARI VOTING SYSTEM - Design Tokens
============================================ */
@theme {
/* 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-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;
}
/* ============================================
Global Styles
============================================ */
l:root {
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%);
}
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* ============================================
Tablet-Optimized Touch Styles
============================================ */
button,
.touch-target {
min-height: 48px;
min-width: 48px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
input {
font-size: 16px; /* Previene zoom su iOS */
}
/* ============================================
Animazioni
============================================ */
@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);
}
}
@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);
}
}
@keyframes blink {
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);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-pulse-error {
animation: pulse-error 1s ease-in-out infinite;
}
.animate-blink {
animation: blink 1.5s step-start infinite;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
/* ============================================
Utility Classes
============================================ */
.text-shadow {
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);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,213 @@
/**
* Active Gate Screen - Focolari Voting System
* Schermata principale del varco attivo
*/
import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components';
import type { RoomInfo, User, RFIDScannerState } from '../types';
interface ActiveGateScreenProps {
roomInfo: RoomInfo;
rfidState: RFIDScannerState;
rfidBuffer: string;
currentUser: User | null;
loading: boolean;
error?: string;
onCancelUser: () => void;
onLogout: () => void;
userTimeoutSeconds?: number;
onUserTimeout: () => void;
}
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>
{/* 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>
{/* 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>
) : (
// 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-4xl font-bold text-focolare-blue mb-4">
Varco Attivo
</h1>
<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">
Passa il badge
</span>
</div>
</div>
</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>
);
}
export default ActiveGateScreen;

View File

@@ -0,0 +1,72 @@
/**
* Error Modal - Focolari Voting System
* Modal per errori
*/
import { Modal, Button } from '../components';
interface ErrorModalProps {
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"
>
Chiudi
</Button>
</div>
</Modal>
);
}
export default ErrorModal;

View File

@@ -0,0 +1,90 @@
/**
* Loading Screen - Focolari Voting System
*/
import { Logo } from '../components';
interface LoadingScreenProps {
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} />
<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>
{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>
);
}
export default LoadingScreen;

View File

@@ -0,0 +1,88 @@
/**
* Success Modal - Focolari Voting System
* Modal fullscreen per conferma ingresso
*/
import { Modal } from '../components';
interface SuccessModalProps {
isOpen: boolean;
onClose: () => void;
welcomeMessage: string;
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>
{/* User Name */}
{userName && (
<h2 className="text-4xl md:text-5xl font-bold mb-4 animate-slide-up">
{userName}
</h2>
)}
{/* 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 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>{`
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}
`}</style>
</div>
</div>
</Modal>
);
}
export default SuccessModal;

View File

@@ -0,0 +1,179 @@
/**
* 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';
interface ValidatorLoginScreenProps {
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);
// 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);
}
};
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>
</div>
<h1 className="text-3xl font-bold text-gray-800 mb-4">
Accesso Varco
</h1>
<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>
<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>
</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;

View File

@@ -0,0 +1,6 @@
// Screens barrel export
export { LoadingScreen } from './LoadingScreen';
export { ValidatorLoginScreen } from './ValidatorLoginScreen';
export { ActiveGateScreen } from './ActiveGateScreen';
export { SuccessModal } from './SuccessModal';
export { ErrorModal } from './ErrorModal';

View File

@@ -0,0 +1,135 @@
/**
* Focolari Voting System - API Service
*/
import type {
RoomInfo,
User,
LoginRequest,
LoginResponse,
EntryRequest,
EntryResponse
} from '../types';
const API_BASE_URL = 'http://localhost:8000';
/**
* 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';
}
}
/**
* Generic fetch wrapper with error handling
*/
async function apiFetch<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.detail || `HTTP Error ${response.status}`,
response.status,
errorData.detail
);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(
'Errore di connessione al server',
0,
'Verifica che il server sia attivo'
);
}
}
// ============================================
// API Endpoints
// ============================================
/**
* GET /info-room
* Ottiene le informazioni sulla sala e la riunione
*/
export async function getRoomInfo(): Promise<RoomInfo> {
return apiFetch<RoomInfo>('/info-room');
}
/**
* POST /login-validate
* Autentica il validatore con badge e password
*/
export async function loginValidator(
badge: string,
password: string
): Promise<LoginResponse> {
const payload: LoginRequest = { badge, password };
return apiFetch<LoginResponse>('/login-validate', {
method: 'POST',
body: JSON.stringify(payload),
});
}
/**
* GET /anagrafica/{badge_code}
* Ottiene i dati anagrafici di un utente tramite badge
*/
export async function getUserByBadge(badgeCode: string): Promise<User> {
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
}
/**
* POST /entry-request
* Registra l'ingresso di un utente
*/
export async function requestEntry(
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),
});
}
// ============================================
// Utility Functions
// ============================================
/**
* 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;
}
}

101
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
}
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 {
// ============================================
// API Response Types
// ============================================
*/
* Focolari Voting System - TypeScript Types

View File

@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
focolari: {
blue: '#0072CE',
orange: '#F5A623',
yellow: '#FFD700',
}
}
},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})