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

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# ================================================
# Focolari Voting System - Root .gitignore
# ================================================
# IDE e Editor
.idea/
.vscode/
*.swp
*.swo
*~
# OS Generated Files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs generici
*.log
logs/
# File ambiente
.env
.env.local
.env.*.local
# Note personali (non committare)
NOTES.md
TODO.md
scratch/
ai-prompts/old_prompts

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# 🗳️ Focolari Voting System
Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focolari**.
## 📖 Descrizione
Applicazione web ottimizzata per tablet che gestisce i varchi d'ingresso alle sale votazione. Il sistema utilizza lettori RFID USB (che emulano tastiera) per identificare validatori e partecipanti.
## 🏗️ Struttura Progetto
```
VotoFocolari/
├── ai-prompts/ # Documentazione sviluppo e prompt
├── backend-mock/ # API mock in Python FastAPI
└── frontend/ # App React + TypeScript + Tailwind
```
## 🚀 Quick Start
### Backend
```bash
cd backend-mock
pipenv install
pipenv run start
# Server: http://localhost:8000
# Docs API: http://localhost:8000/docs
```
### Frontend
```bash
cd frontend
npm install
npm run dev
# App: http://localhost:5173
```
## 📚 Documentazione
Per dettagli tecnici, consulta la cartella `ai-prompts/`:
- `00-welcome-agent.md` - Panoramica progetto
- `01-backend-plan.md` - Piano sviluppo backend
- `02-frontend-plan.md` - Piano sviluppo frontend
## 🔐 Credenziali Test
- **Badge Validatore:** `999999`
- **Password:** `focolari`
## 📄 Licenza
Progetto privato - Movimento dei Focolari

View File

@@ -0,0 +1,206 @@
# 🎯 Focolari Voting System - Guida Agente
## Panoramica Progetto
**Nome:** Sistema Controllo Accessi "Focolari Voting System"
**Committente:** Movimento dei Focolari
**Scopo:** Gestione dei varchi di accesso per le assemblee di voto del Movimento.
### Contesto d'Uso
Il sistema funziona su **tablet Android** (via browser Chrome) posizionati ai varchi d'ingresso delle sale votazione. Ogni tablet è collegato a un **lettore RFID USB** che emula tastiera.
---
## 🏗️ Architettura
```
VotoFocolari/
├── ai-prompts/ # Documentazione e piani di sviluppo
├── backend-mock/ # Python FastAPI (server mock)
│ ├── main.py # Entry point con argparse
│ ├── Pipfile # Dipendenze pipenv
│ ├── api/ # Routes FastAPI
│ ├── schemas/ # Modelli Pydantic
│ └── data/ # Dataset JSON (default, test)
└── frontend/ # React + TypeScript + Vite + Tailwind
└── src/
├── App.tsx # State machine principale
├── hooks/ # Custom hooks (RFID scanner)
├── components/ # UI components
├── screens/ # Schermate complete
├── services/ # API layer
├── types/ # TypeScript definitions
└── tests/ # Test automatici use case
```
---
## 🔧 Stack Tecnologico
### Backend Mock
- **Python 3.10+**
- **FastAPI** - Framework web asincrono
- **Uvicorn** - ASGI server
- **Pydantic** - Validazione dati
- **pipenv** - Gestione ambiente virtuale
- **argparse** - Parametri CLI (porta, dataset)
### Frontend
- **React 19** - UI Library
- **TypeScript 5.9** - Type safety
- **Vite 7** - Build tool
- **Tailwind CSS 4** - Styling utility-first
- **React Router** - Routing (/, /debug)
- **Vitest** - Testing framework
- **Design:** Ottimizzato per touch tablet
---
## 🎨 Design System
| Colore | Hex | Uso |
|--------|-----|-----|
| Blu Istituzionale | `#0072CE` | Colore primario, brand |
| Arancio Accento | `#F5A623` | Azioni secondarie |
| Giallo | `#FFD700` | Evidenze |
| Verde Success | `#22C55E` | Conferme, ammesso |
| Rosso Error | `#EF4444` | Errori, non ammesso |
---
## 📟 Logica RFID (CRITICA)
Il lettore RFID simula una tastiera. **Non possiamo distinguerlo dalla digitazione umana** in base alla velocità.
### Protocollo
- **Formato:** `<start_sentinel><codice><end_sentinel>`
- **Esempio US:** `;00012345?`
- **Esempio IT:** `ò00012345_`
### Pattern Supportati
```typescript
const VALID_PATTERNS = [
{ start: ';', end: '?' }, // Layout US
{ start: 'ò', end: '_' }, // Layout IT
];
```
### Strategia
1. Ascolto globale `keydown`
2. Se ricevo un carattere `start` → avvio buffer + timeout (2.5s)
3. Accumulo caratteri nel buffer
4. Se ricevo il corretto `end` → emetto codice pulito
5. Se timeout scade → scarto buffer
---
## 🔄 State Machine Applicativa
```
LOADING → WAITING_VALIDATOR → [badge validatore]
→ VALIDATOR_PASSWORD → [password OK]
→ GATE_ACTIVE → [badge partecipante]
→ SHOWING_USER → [badge validatore]
→ SUCCESS_MODAL (5s, carosello multilingua) → GATE_ACTIVE
```
---
## 🌍 Messaggi Benvenuto (Frontend)
Il backend risponde in modo **asettico** (solo `success: true`).
Il frontend mostra un **carosello automatico** di messaggi multilingua:
- Italiano, English, Français, Deutsch, Español, Português, 中文, 日本語
Scorrimento ogni ~2 secondi, durata modale 5 secondi.
---
## 📁 File Chiave
| File | Descrizione |
|------|-------------|
| `backend-mock/main.py` | Entry point con argparse |
| `backend-mock/api/routes.py` | Definizione endpoint |
| `backend-mock/schemas/models.py` | Modelli Pydantic |
| `backend-mock/data/*.json` | Dataset utenti |
| `frontend/src/hooks/useRFIDScanner.ts` | Cuore del sistema - gestione lettore |
| `frontend/src/App.tsx` | State machine e orchestrazione |
| `frontend/src/components/WelcomeCarousel.tsx` | Carosello multilingua |
| `frontend/src/screens/DebugScreen.tsx` | Diagnostica RFID |
| `frontend/src/tests/` | Test automatici use case |
---
## 🚀 Quick Start
### Backend
```bash
cd backend-mock
pipenv install
pipenv run python main.py # Default
pipenv run python main.py -p 9000 # Porta custom
pipenv run python main.py -d data/test.json # Dataset test
```
### Frontend
```bash
cd frontend
npm install
npm run dev # Sviluppo
npm run test # Test suite
npm run test:ui # Test con UI
```
---
## 🧪 Test Automatici
Suite di test per validazione use case:
| Test | Descrizione |
|------|-------------|
| UC01 | Login validatore |
| UC02 | Accesso partecipante ammesso |
| UC03 | Accesso negato |
| UC04 | Timeout sessione |
| UC05 | Cambio rapido badge |
| UC06 | Pattern RFID multipli |
---
## 🔍 Debug
### Console Browser
Tutti i log sono prefissati:
- `[RFID]` - Eventi lettore badge
- `[FLOW]` - Transizioni stato
- `[API]` - Chiamate HTTP
### Pagina Debug (`/debug`)
Accesso: logo cliccabile 5 volte.
Mostra in tempo reale:
- Ultimi 20 tasti premuti
- Stato scanner (idle/scanning)
- Buffer corrente
- Pattern attivo
---
## ⚠️ Note Importanti
1. **Badge Validatore:** `999999` con password `focolari`
2. **Sessione:** 30 minuti di timeout, salvata in localStorage
3. **Timeout utente:** 60 secondi sulla schermata decisione
4. **Multi-layout:** Il sistema supporta RFID su tastiera US e IT
5. **Backend asettico:** Nessun messaggio multilingua dal server
6. **Git:** Il progetto sfrutta un Repository per versionare e sviluppare, ma solo l'utente può eseguire comandi git, eccetto se richiesto diversamente, l'agente può solo chiedere di eseguire o suggerire cosa fare, ma mai prendere iniziative
---
## 📚 Documentazione Correlata
- `01-backend-plan.md` - Piano sviluppo backend
- `02-frontend-plan.md` - Piano sviluppo frontend

View File

@@ -0,0 +1,181 @@
# 📋 Piano Sviluppo Backend Mock
## Obiettivo
Server FastAPI mock per simulare il backend del sistema di controllo accessi.
Struttura modulare con separazione tra API, modelli e dati.
---
## Struttura Target
```
backend-mock/
├── main.py # Entry point con argparse
├── Pipfile # Dipendenze pipenv
├── requirements.txt # Backup dipendenze
├── .gitignore
├── api/
│ ├── __init__.py
│ └── routes.py # Definizione endpoint
├── schemas/
│ ├── __init__.py
│ └── models.py # Modelli Pydantic
└── data/
├── users_default.json # Dataset utenti default
└── users_test.json # Dataset per test
```
---
## Checklist Sviluppo
### 1. Setup Progetto
- [x] Creare cartella `backend-mock/`
- [x] Creare `Pipfile` per pipenv
- [x] Configurare `.gitignore` per Python
- [ ] Creare struttura cartelle (`api/`, `schemas/`, `data/`)
### 2. Modelli Pydantic (`schemas/models.py`)
- [x] `LoginRequest` - badge + password
- [x] `EntryRequest` - user_badge + validator_password
- [x] `UserResponse` - dati utente + warning opzionale
- [x] `RoomInfoResponse` - nome sala + meeting_id
- [x] `LoginResponse` - success + message + token
- [x] `EntryResponse` - success + message (SENZA welcome_message)
- [ ] **DA FARE:** Spostare modelli in file dedicato
### 3. Dati Mock (`data/*.json`)
- [x] Costanti validatore (badge `999999`, password `focolari`)
- [x] Lista utenti mock (7 utenti con dati realistici)
- [x] Mix di ruoli: Votante, Tecnico, Ospite
- [x] Alcuni con `ammesso: false` per test
- [x] URL foto placeholder (randomuser.me)
- [ ] **DA FARE:** Estrarre dati in file JSON separato
- [ ] **DA FARE:** Creare dataset alternativo per test
- [ ] **DA FARE:** Caricare dati dinamicamente all'avvio
**Nota:** I messaggi di benvenuto multilingua sono stati **RIMOSSI** dal backend.
Il frontend gestirà autonomamente la visualizzazione internazionale con carosello.
### 4. Routes API (`api/routes.py`)
- [x] `GET /info-room` - info sala
- [x] `POST /login-validate` - autenticazione validatore
- [x] `GET /anagrafica/{badge_code}` - ricerca utente
- [x] Pulizia caratteri sentinel dal badge
- [x] Confronto con e senza zeri iniziali
- [x] Warning automatico se non ammesso
- [x] `POST /entry-request` - registrazione ingresso
- [x] Verifica password validatore
- [x] Verifica utente ammesso
- [x] Risposta asettica (solo success + message)
- [ ] **DA FARE:** Spostare routes in file dedicato
### 5. Entry Point (`main.py`)
- [x] Blocco `if __name__ == "__main__"`
- [x] Configurazione uvicorn (host, port)
- [x] Messaggi console all'avvio
- [ ] **DA FARE:** Implementare argparse con opzioni:
- `--port` / `-p` : porta server (default: 8000)
- `--data` / `-d` : path file JSON dati (default: `data/users_default.json`)
- `--host` : host binding (default: `0.0.0.0`)
- [ ] **DA FARE:** Caricamento dinamico dati da JSON
- [ ] **DA FARE:** Import routes da modulo `api`
### 6. Struttura Base FastAPI
- [x] Import FastAPI e dipendenze
- [x] Configurazione app con titolo e descrizione
- [x] Middleware CORS (allow all origins)
- [x] Endpoint root `/` per health check
- [ ] **DA FARE:** Refactor in struttura modulare
---
## Schema File JSON Dati
```json
{
"validator": {
"badge": "999999",
"password": "focolari"
},
"room": {
"room_name": "Sala Assemblea",
"meeting_id": "VOT-2024"
},
"users": [
{
"badge_code": "000001",
"nome": "Maria",
"cognome": "Rossi",
"url_foto": "https://...",
"ruolo": "Votante",
"ammesso": true
}
]
}
```
---
## Comandi Esecuzione
### Avvio Standard
```bash
cd backend-mock
pipenv install
pipenv run python main.py
```
### Avvio con Parametri Custom
```bash
# Porta diversa
pipenv run python main.py --port 9000
# Dataset test
pipenv run python main.py --data data/users_test.json
# Combinato
pipenv run python main.py -p 9000 -d data/users_test.json
```
---
## Test Rapidi
```bash
# Health check
curl http://localhost:8000/
# Info sala
curl http://localhost:8000/info-room
# Ricerca utente
curl http://localhost:8000/anagrafica/000001
# Login validatore
curl -X POST http://localhost:8000/login-validate \
-H "Content-Type: application/json" \
-d '{"badge": "999999", "password": "focolari"}'
# Richiesta ingresso (risposta asettica)
curl -X POST http://localhost:8000/entry-request \
-H "Content-Type: application/json" \
-d '{"user_badge": "000001", "validator_password": "focolari"}'
```
---
## Note Implementative
- Gli endpoint puliscono automaticamente i caratteri `;`, `?`, `ò`, `_` dai badge
- Il confronto badge avviene sia con che senza zeri iniziali
- Le risposte seguono lo standard HTTP (200 OK, 401 Unauthorized, 404 Not Found, 403 Forbidden)
- La documentazione OpenAPI è auto-generata su `/docs`
- **Risposta `/entry-request`:** JSON asettico `{ success: true, message: "..." }` senza messaggi multilingua

View File

@@ -0,0 +1,305 @@
# 📋 Piano Sviluppo Frontend
## Obiettivo
Applicazione React per tablet che gestisce il flusso di accesso ai varchi votazione, con lettura badge RFID.
Include sistema di test automatici per validazione e regression detection.
---
## Checklist Sviluppo
### 1. Setup Progetto
- [x] Inizializzare Vite + React + TypeScript
- [x] Installare Tailwind CSS 4
- [x] Configurare `tsconfig.json`
- [x] Configurare `vite.config.ts`
- [x] Configurare `.gitignore`
### 2. Design System
- [x] Configurare colori custom in Tailwind
- [x] `focolare-blue: #0072CE`
- [x] `focolare-orange: #F5A623`
- [x] `focolare-yellow: #FFD700`
- [x] Stili globali in `index.css`
- [x] Animazioni custom (fade-in, slide-up, pulse-error, blink)
- [x] Classe `.glass` per effetto glassmorphism
### 3. Tipi TypeScript (`types/index.ts`)
- [x] `RoomInfo` - info sala
- [x] `User` - dati utente
- [x] `LoginRequest/Response`
- [x] `EntryRequest/Response` (SENZA welcome_message)
- [x] `AppState` - stati applicazione
- [x] `ValidatorSession` - sessione validatore
- [x] `RFIDScannerState` - stato scanner
- [x] `RFIDScanResult` - risultato scan
- [ ] **DA FARE:** Aggiornare `EntryResponse` rimuovendo `welcome_message`
### 4. API Service (`services/api.ts`)
- [x] Classe `ApiError` custom
- [x] Funzione `apiFetch` generica con error handling
- [x] `getRoomInfo()` - GET /info-room
- [x] `loginValidator()` - POST /login-validate
- [x] `getUserByBadge()` - GET /anagrafica/{badge}
- [x] `requestEntry()` - POST /entry-request
- [ ] **DA FARE:** Logging con prefisso `[API]`
### 5. Hook RFID Scanner (`hooks/useRFIDScanner.ts`)
#### Implementazione Base (v1)
- [x] Listener `keydown` globale
- [x] Stati: idle → scanning → idle
- [x] Singolo pattern (`;` / `?`)
- [x] Timeout sicurezza (3s)
- [x] `preventDefault` durante scan
- [x] Callback `onScan`, `onTimeout`, `onScanStart`
#### ⚠️ AGGIORNAMENTO RICHIESTO (v2) - Multi-Pattern
- [ ] Supporto pattern multipli (US, IT, altri)
```typescript
const VALID_PATTERNS = [
{ start: ';', end: '?' }, // Layout US
{ start: 'ò', end: '_' }, // Layout IT
];
```
- [ ] Rilevamento automatico pattern in uso
- [ ] Memorizzazione pattern attivo durante scan
- [ ] Validazione `end` solo per pattern corretto
- [ ] Logging avanzato con prefisso `[RFID]`
- [ ] Esportare info pattern attivo per debug page
### 6. Componenti UI (`components/`)
- [x] `Logo.tsx` - logo Focolari
- [x] `Button.tsx` - varianti primary/secondary/danger
- [x] `Input.tsx` - campo input styled
- [x] `Modal.tsx` - modale base
- [x] `RFIDStatus.tsx` - indicatore stato scanner
- [x] `UserCard.tsx` - card utente con foto e ruolo
- [x] `CountdownTimer.tsx` - timer con progress bar
- [x] `index.ts` - barrel export
- [ ] **DA FARE:** `WelcomeCarousel.tsx` - carosello messaggi multilingua
### 7. Schermate (`screens/`)
- [x] `LoadingScreen.tsx` - caricamento iniziale
- [x] `ValidatorLoginScreen.tsx` - attesa badge + password
- [x] `ActiveGateScreen.tsx` - varco attivo + scheda utente
- [x] `SuccessModal.tsx` - conferma ingresso fullscreen
- [x] `ErrorModal.tsx` - errore fullscreen
- [x] `index.ts` - barrel export
- [ ] **DA FARE:** `DebugScreen.tsx` - pagina diagnostica RFID
### 8. State Machine (`App.tsx`)
- [x] Stati applicazione gestiti
- [x] Integrazione `useRFIDScanner`
- [x] Gestione sessione validatore (localStorage)
- [x] Timeout sessione 30 minuti
- [x] Timeout utente 60 secondi
- [x] Cambio rapido badge partecipante
- [x] Conferma con badge validatore
- [ ] **DA FARE:** Logging transizioni con prefisso `[FLOW]`
### 9. Modale Successo - Carosello Internazionale
#### ⚠️ MODIFICA RICHIESTA
Il backend **NON** restituisce più messaggi multilingua.
Il frontend gestisce autonomamente la visualizzazione con un **carosello automatico**.
**Specifiche:**
- [ ] Creare componente `WelcomeCarousel.tsx`
- [ ] Lista messaggi di benvenuto in diverse lingue:
```typescript
const WELCOME_MESSAGES = [
{ 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: 'ようこそ!' },
];
```
- [ ] Scorrimento automatico (es. ogni 2 secondi)
- [ ] Animazione transizione fluida (fade o slide)
- [ ] Modale fullscreen verde con carosello al centro
- [ ] Durata totale modale: 5 secondi
### 10. Debug & Diagnostica
#### ⚠️ DA IMPLEMENTARE
- [ ] Pagina `/debug` dedicata
- [ ] Log ultimi 20 tasti premuti (key + code)
- [ ] Stato scanner real-time (idle/scanning)
- [ ] Buffer corrente
- [ ] Pattern attivo (US/IT/...)
- [ ] Ultimo codice rilevato
- [ ] Timestamp eventi
- [ ] Link/pulsante nascosto per accesso debug (es. logo cliccabile 5 volte)
- [ ] Logging console strutturato:
- [ ] `[RFID]` - eventi scanner
- [ ] `[FLOW]` - transizioni stato
- [ ] `[API]` - chiamate HTTP
### 11. Routing
- [ ] Installare React Router
- [ ] Route principale `/`
- [ ] Route debug `/debug`
### 12. Test Automatici (E2E / Use Case Validation)
#### ⚠️ NUOVA SEZIONE
Sistema di test per validazione formale dei flussi e regression detection.
**Struttura:**
```
frontend/
├── src/
│ └── tests/
│ ├── usecase/
│ │ ├── UC01_ValidatorLogin.test.ts
│ │ ├── UC02_ParticipantAccess.test.ts
│ │ ├── UC03_DeniedAccess.test.ts
│ │ ├── UC04_SessionTimeout.test.ts
│ │ ├── UC05_QuickBadgeSwitch.test.ts
│ │ └── UC06_RFIDMultiPattern.test.ts
│ └── helpers/
│ ├── mockRFID.ts # Simulatore eventi tastiera RFID
│ └── testUtils.ts # Utility comuni
```
**Use Case da Testare:**
- [ ] **UC01 - Login Validatore**
- Simula badge validatore (`;999999?`)
- Inserisce password corretta
- Verifica transizione a stato `gate-active`
- Verifica sessione salvata in localStorage
- [ ] **UC02 - Accesso Partecipante Ammesso**
- Da stato `gate-active`
- Simula badge partecipante ammesso
- Verifica caricamento dati utente
- Simula badge validatore per conferma
- Verifica modale successo con carosello
- Verifica ritorno a `gate-active`
- [ ] **UC03 - Accesso Negato**
- Simula badge partecipante NON ammesso
- Verifica visualizzazione warning rosso
- Verifica che conferma validatore sia bloccata
- Verifica pulsante annulla funzionante
- [ ] **UC04 - Timeout Sessione**
- Verifica scadenza sessione 30 minuti
- Verifica redirect a login validatore
- Verifica pulizia localStorage
- [ ] **UC05 - Cambio Rapido Badge**
- Con utente a schermo
- Simula nuovo badge partecipante
- Verifica sostituzione immediata dati
- [ ] **UC06 - Pattern RFID Multipli**
- Testa pattern US (`;` / `?`)
- Testa pattern IT (`ò` / `_`)
- Verifica stesso risultato finale
**Dipendenze Test:**
```bash
npm install -D vitest @testing-library/react @testing-library/user-event jsdom
```
**Script npm:**
```json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
```
---
## Correzioni Necessarie
### Hook RFID - Da Aggiornare
Il file `useRFIDScanner.ts` attualmente supporta **solo** il pattern US (`;` / `?`).
**Modifiche richieste:**
1. Aggiungere costante `VALID_PATTERNS` con pattern multipli
2. Modificare logica `handleKeyDown` per:
- Riconoscere qualsiasi `start` sentinel
- Memorizzare quale pattern è in uso
- Validare solo con l'`end` corrispondente
3. Aggiungere stato `activePattern` per debug
4. Migliorare logging
### Success Modal - Da Aggiornare
Attualmente usa `welcome_message` dal backend.
**Modifiche richieste:**
1. Rimuovere dipendenza da `welcome_message` API
2. Implementare `WelcomeCarousel` con messaggi hardcoded
3. Carosello auto-scroll ogni 2 secondi
4. Animazioni fluide tra messaggi
### Logging Console
Attualmente i log usano messaggi generici. Aggiornare tutti i `console.log` con prefissi standardizzati.
---
## Dipendenze da Aggiungere
```bash
# Routing
npm install react-router-dom
# Testing
npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitest/ui @vitest/coverage-v8
```
---
## Comandi Esecuzione
```bash
cd frontend
npm install
npm run dev # Sviluppo
npm run build # Build produzione
npm run test # Test suite
npm run test:ui # Test con UI interattiva
npm run test:coverage # Coverage report
```
---
## Note UI/UX
- Font grandi per leggibilità tablet
- Pulsanti touch-friendly (min 48px)
- Feedback visivo immediato su azioni
- Animazioni fluide ma non invasive
- Supporto landscape e portrait
- Carosello benvenuto: transizioni smooth, leggibilità massima

30
backend-mock/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
ENV/
env/
# Pipenv
Pipfile.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Local environment
.env
.env.local

18
backend-mock/Pipfile Normal file
View File

@@ -0,0 +1,18 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
fastapi = ">=0.109.0"
uvicorn = {extras = ["standard"], version = ">=0.27.0"}
pydantic = ">=2.5.0"
[dev-packages]
[requires]
python_version = "3.10"
[scripts]
start = "python main.py"
dev = "uvicorn main:app --reload --host 0.0.0.0 --port 8000"

277
backend-mock/main.py Normal file
View File

@@ -0,0 +1,277 @@
"""
Focolari Voting System - Backend Mock
Sistema di controllo accessi per votazioni del Movimento dei Focolari
"""
import random
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
app = FastAPI(
title="Focolari Voting System API",
description="Backend mock per il sistema di controllo accessi",
version="1.0.0"
)
# CORS abilitato per tutti
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================
# MODELLI PYDANTIC
# ============================================
class LoginRequest(BaseModel):
badge: str
password: str
class EntryRequest(BaseModel):
user_badge: str
validator_password: str
class UserResponse(BaseModel):
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: str
ammesso: bool
warning: Optional[str] = None
class RoomInfoResponse(BaseModel):
room_name: str
meeting_id: str
class LoginResponse(BaseModel):
success: bool
message: str
token: Optional[str] = None
class EntryResponse(BaseModel):
success: bool
message: str
welcome_message: Optional[str] = None
# ============================================
# DATI MOCK
# ============================================
# Credenziali Validatore
VALIDATOR_BADGE = "999999"
VALIDATOR_PASSWORD = "focolari"
MOCK_TOKEN = "focolare-validator-token-2024"
# Lista utenti mock
USERS_DB = [
{
"badge_code": "000001",
"nome": "Maria",
"cognome": "Rossi",
"url_foto": "https://randomuser.me/api/portraits/women/1.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000002",
"nome": "Giuseppe",
"cognome": "Bianchi",
"url_foto": "https://randomuser.me/api/portraits/men/2.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000003",
"nome": "Anna",
"cognome": "Verdi",
"url_foto": "https://randomuser.me/api/portraits/women/3.jpg",
"ruolo": "Tecnico",
"ammesso": True
},
{
"badge_code": "000004",
"nome": "Francesco",
"cognome": "Neri",
"url_foto": "https://randomuser.me/api/portraits/men/4.jpg",
"ruolo": "Ospite",
"ammesso": False # Non ammesso!
},
{
"badge_code": "000005",
"nome": "Lucia",
"cognome": "Gialli",
"url_foto": "https://randomuser.me/api/portraits/women/5.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000006",
"nome": "Paolo",
"cognome": "Blu",
"url_foto": "https://randomuser.me/api/portraits/men/6.jpg",
"ruolo": "Votante",
"ammesso": False # Non ammesso!
},
{
"badge_code": "123456",
"nome": "Teresa",
"cognome": "Martini",
"url_foto": "https://randomuser.me/api/portraits/women/7.jpg",
"ruolo": "Votante",
"ammesso": True
},
]
# Messaggi di benvenuto multilingua
WELCOME_MESSAGES = [
"Benvenuto! / Welcome!",
"Bienvenue! / Willkommen!",
"Bienvenido! / Bem-vindo!",
"欢迎! / 歓迎!",
"Добро пожаловать! / مرحبا!"
]
# ============================================
# ENDPOINTS
# ============================================
@app.get("/")
async def root():
"""Endpoint di test per verificare che il server sia attivo"""
return {"status": "ok", "message": "Focolari Voting System API is running"}
@app.get("/info-room", response_model=RoomInfoResponse)
async def get_room_info():
"""
Restituisce le informazioni sulla sala e la riunione corrente.
"""
return RoomInfoResponse(
room_name="Sala Assemblea",
meeting_id="VOT-2024"
)
@app.post("/login-validate", response_model=LoginResponse)
async def login_validate(request: LoginRequest):
"""
Valida le credenziali del validatore.
Il badge deve essere quello del validatore (999999) e la password corretta.
"""
# Pulisci il badge da eventuali caratteri sentinel
clean_badge = request.badge.strip().replace(";", "").replace("?", "")
if clean_badge != VALIDATOR_BADGE:
raise HTTPException(
status_code=401,
detail="Badge validatore non riconosciuto"
)
if request.password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password non corretta"
)
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=MOCK_TOKEN
)
@app.get("/anagrafica/{badge_code}", response_model=UserResponse)
async def get_user_anagrafica(badge_code: str):
"""
Cerca un utente tramite il suo badge code.
Restituisce i dati anagrafici e un warning se non è ammesso.
"""
# Pulisci il badge da eventuali caratteri sentinel
clean_badge = badge_code.strip().replace(";", "").replace("?", "")
# Normalizza: rimuovi zeri iniziali per il confronto, ma cerca anche con zeri
for user in USERS_DB:
if user["badge_code"] == clean_badge or user["badge_code"].lstrip("0") == clean_badge.lstrip("0"):
response = UserResponse(
badge_code=user["badge_code"],
nome=user["nome"],
cognome=user["cognome"],
url_foto=user["url_foto"],
ruolo=user["ruolo"],
ammesso=user["ammesso"]
)
# Aggiungi warning se non ammesso
if not user["ammesso"]:
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!"
return response
# Utente non trovato
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_badge} non trovato nel sistema"
)
@app.post("/entry-request", response_model=EntryResponse)
async def process_entry_request(request: EntryRequest):
"""
Processa una richiesta di ingresso.
Richiede il badge dell'utente e la password del validatore.
"""
# Pulisci i dati
clean_user_badge = request.user_badge.strip().replace(";", "").replace("?", "")
# Verifica password validatore
if request.validator_password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
# Cerca l'utente
user_found = None
for user in USERS_DB:
if user["badge_code"] == clean_user_badge or user["badge_code"].lstrip("0") == clean_user_badge.lstrip("0"):
user_found = user
break
if not user_found:
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_user_badge} non trovato"
)
if not user_found["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user_found['nome']} {user_found['cognome']} NON è autorizzato all'ingresso"
)
# Successo! Genera messaggio di benvenuto casuale
welcome = random.choice(WELCOME_MESSAGES)
return EntryResponse(
success=True,
message=f"Ingresso registrato per {user_found['nome']} {user_found['cognome']}",
welcome_message=welcome
)
# ============================================
# AVVIO SERVER
# ============================================
if __name__ == "__main__":
import uvicorn
print("🚀 Avvio Focolari Voting System Backend...")
print("📍 Server in ascolto su http://localhost:8000")
print("📚 Documentazione API su http://localhost:8000/docs")
uvicorn.run(app, host="0.0.0.0", port=8000)

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()],
})