feat: Controllo accessi RFID completo con gestione sessioni

- Aggiunto supporto multi-pattern RFID (US/IT layout)
- Implementata invalidazione sessioni al restart del server
- Schermata "badge non trovato" con countdown 30s
- Notifica quando badge validatore passato senza utente
- Database aggiornato con badge reali di test
- Layout ottimizzato per tablet orizzontale
- Banner NumLock per desktop
- Toggle visibilità password
- Carosello benvenuto multilingua (10 lingue)
- Pagina debug RFID (/debug)
This commit is contained in:
2026-01-17 20:06:50 +01:00
parent 21b509c6ba
commit e68f299feb
48 changed files with 3625 additions and 2445 deletions

View File

@@ -4,39 +4,58 @@ Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focola
## 📖 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.
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/
├── dev.sh # Script sviluppo (install, dev, server, build, ...)
├── ai-prompts/ # Documentazione sviluppo e prompt
├── backend-mock/ # API mock in Python FastAPI
│ └── static/ # Frontend buildato (generato)
└── frontend/ # App React + TypeScript + Tailwind
```
## 🚀 Quick Start
### Backend
### Setup Iniziale
```bash
cd backend-mock
pipenv install
pipenv run start
# Server: http://localhost:8000
# Docs API: http://localhost:8000/docs
./dev.sh install
```
### Frontend
### Sviluppo (hot reload)
```bash
cd frontend
npm install
npm run dev
# App: http://localhost:5173
./dev.sh dev
# Backend API: http://localhost:8000
# Frontend Dev: http://localhost:5173
```
### Produzione Locale
```bash
./dev.sh server
# App completa: http://localhost:8000
```
### Altri Comandi
```bash
./dev.sh build # Solo build frontend
./dev.sh backend # Solo backend (API)
./dev.sh frontend # Solo frontend dev
./dev.sh shell # Shell pipenv backend
./dev.sh clean # Pulisce build e cache
./dev.sh help # Mostra tutti i comandi
```
## 📚 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
@@ -46,6 +65,10 @@ Per dettagli tecnici, consulta la cartella `ai-prompts/`:
- **Badge Validatore:** `999999`
- **Password:** `focolari`
## 🔍 Debug
Accedi a `/debug` per diagnostica RFID in tempo reale.
## 📄 Licenza
Progetto privato - Movimento dei Focolari

View File

@@ -1,206 +1,150 @@
# 🎯 Focolari Voting System - Guida Agente
# 🗳️ Focolari Voting System - Welcome Agent
## 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.
Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focolari**.
Applicazione web ottimizzata per **tablet in orizzontale** che gestisce i varchi d'ingresso alle sale votazione tramite
**lettori RFID USB** (emulano tastiera).
---
## 🏗️ Architettura
## Stack Tecnologico
### Backend Mock
- **Python 3.11+** con **FastAPI**
- **Pydantic** per validazione dati
- **Uvicorn** come server ASGI
- **Pipenv** per gestione dipendenze
### Frontend
- **React 18** + **TypeScript**
- **Vite** come build tool
- **Tailwind CSS 4** per styling
- **React Router** per navigazione
---
## Struttura Progetto
```
VotoFocolari/
├── ai-prompts/ # Documentazione e piani di sviluppo
├── backend-mock/ # Python FastAPI (server mock)
├── dev.sh # Script di sviluppo (install, dev, server, ...)
├── README.md
├── ai-prompts/ # Documentazione sviluppo
│ ├── 00-welcome-agent.md # Questo file
│ ├── 01-backend-plan.md # Piano backend
│ └── 02-frontend-plan.md # Piano frontend
├── backend-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
│ ├── Pipfile # Dipendenze Python
│ ├── api/routes.py # Endpoint API
│ ├── schemas/models.py # Modelli Pydantic
│ └── data/
│ ├── users_default.json
│ └── users_test.json
└── frontend/
├── package.json
├── vite.config.ts
└── src/
├── App.tsx # State machine principale
├── hooks/ # Custom hooks (RFID scanner)
├── hooks/ # useRFIDScanner
├── components/ # UI components
├── screens/ # Schermate complete
├── services/ # API layer
── types/ # TypeScript definitions
└── tests/ # Test automatici use case
├── screens/ # Schermate
├── services/ # API client
── types/ # TypeScript types
```
---
## 🔧 Stack Tecnologico
## Funzionalità Principali
### Backend Mock
- **Python 3.10+**
- **FastAPI** - Framework web asincrono
- **Uvicorn** - ASGI server
- **Pydantic** - Validazione dati
- **pipenv** - Gestione ambiente virtuale
- **argparse** - Parametri CLI (porta, dataset)
### Flusso Utente
### 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
1. **Login Validatore**: Passa badge + inserisci password → Sessione 30 min
2. **Attesa Partecipante**: Schermata grande "Passa il badge"
3. **Visualizzazione Utente**: Card con foto, nome, ruolo, stato ammissione
4. **Conferma Ingresso**: Validatore ripassa il badge → Carosello benvenuto multilingua
### Gestione RFID
- **Multi-pattern**: Supporta layout tastiera US (`;?`) e IT (`ò_`)
- **Timeout 2.5s**: Per scansioni accidentali
- **ESC annulla**: Scansione in corso
- **Enter handling**: Gestito automaticamente
### Sicurezza Sessioni
- Sessione salvata in localStorage
- **Invalidazione automatica** se il server riparte
- Timeout 30 minuti
---
## 🎨 Design System
## Comandi Rapidi
| 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
```
# Setup iniziale
./dev.sh install
### Frontend
```bash
cd frontend
npm install
npm run dev # Sviluppo
npm run test # Test suite
npm run test:ui # Test con UI
# Sviluppo (hot reload)
./dev.sh dev
# Frontend: http://localhost:5173
# Backend: http://localhost:8000
# Produzione locale
./dev.sh server
# App completa: http://localhost:8000
# Debug RFID
# Vai a http://localhost:8000/debug
```
---
## 🧪 Test Automatici
## Badge di Test
Suite di test per validazione use case:
| Badge | Nome | Ruolo | Ammesso |
|--------------|----------------|---------|---------------|
| `0008988288` | Marco Bianchi | Votante | ✅ |
| `0007399575` | Laura Rossi | Votante | ✅ |
| `0000514162` | Giuseppe Verdi | Tecnico | ❌ |
| `0006478281` | - | - | ⚠️ Non nel DB |
| Test | Descrizione |
|------|-------------|
| UC01 | Login validatore |
| UC02 | Accesso partecipante ammesso |
| UC03 | Accesso negato |
| UC04 | Timeout sessione |
| UC05 | Cambio rapido badge |
| UC06 | Pattern RFID multipli |
**Password validatore:** `focolari`
---
## 🔍 Debug
## Dove Trovare Cosa
### 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
| Cosa cerchi | Dove guardare |
|----------------------------|----------------------------------------|
| Logica flusso applicazione | `frontend/src/App.tsx` |
| Hook lettura RFID | `frontend/src/hooks/useRFIDScanner.ts` |
| Chiamate API | `frontend/src/services/api.ts` |
| Endpoint backend | `backend-mock/api/routes.py` |
| Dati mock utenti | `backend-mock/data/users_default.json` |
| Configurazione Vite | `frontend/vite.config.ts` |
---
## ⚠️ Note Importanti
## 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
1. **Layout Tastiera**: Il lettore RFID potrebbe inviare caratteri diversi in base al layout OS. La pagina `/debug`
aiuta a diagnosticare.
2. **Server Restart**: Quando il server riparte, tutte le sessioni frontend vengono invalidate (controllato via
`server_start_time`).
3. **Badge Validatore**: Qualsiasi badge può diventare validatore se la password è corretta. Il badge viene memorizzato
nella sessione.
4. **NumLock**: Su desktop, viene mostrato un banner per ricordare di attivare NumLock.
---
## 📚 Documentazione Correlata
## TODO (da concordare con committenti)
- `01-backend-plan.md` - Piano sviluppo backend
- `02-frontend-plan.md` - Piano sviluppo frontend
- [ ] Verificare se il badge validatore debba essere validato anche lato server
- [ ] Test automatici E2E per regression detection

View File

@@ -13,7 +13,6 @@ Struttura modulare con separazione tra API, modelli e dati.
backend-mock/
├── main.py # Entry point con argparse
├── Pipfile # Dipendenze pipenv
├── requirements.txt # Backup dipendenze
├── .gitignore
├── api/
│ ├── __init__.py
@@ -22,7 +21,7 @@ backend-mock/
│ ├── __init__.py
│ └── models.py # Modelli Pydantic
└── data/
├── users_default.json # Dataset utenti default
├── users_default.json # Dataset utenti default (badge reali)
└── users_test.json # Dataset per test
```
@@ -35,36 +34,40 @@ backend-mock/
- [x] Creare cartella `backend-mock/`
- [x] Creare `Pipfile` per pipenv
- [x] Configurare `.gitignore` per Python
- [ ] Creare struttura cartelle (`api/`, `schemas/`, `data/`)
- [x] 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] `RoomInfoResponse` - nome sala + meeting_id + **server_start_time**
- [x] `LoginResponse` - success + message + token
- [x] `EntryResponse` - success + message (SENZA welcome_message)
- [ ] **DA FARE:** Spostare modelli in file dedicato
- [x] 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
- [x] Password validatore (solo password, badge gestito dal frontend)
- [x] Lista utenti mock con **badge reali**:
- `0008988288` - Marco Bianchi (Votante, ammesso)
- `0007399575` - Laura Rossi (Votante, ammessa)
- `0000514162` - Giuseppe Verdi (Tecnico, NON ammesso)
- `0006478281` - **NON nel DB** (per test "non trovato")
- [x] Estrarre dati in file JSON separato
- [x] Creare dataset alternativo per test (`users_test.json`)
- [x] 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.
**Nota:** I messaggi di benvenuto multilingua sono gestiti dal frontend con carosello.
**TODO (da concordare con committenti):**
- Valutare se `login-validate` debba ricevere e verificare anche il badge del validatore
### 4. Routes API (`api/routes.py`)
- [x] `GET /info-room` - info sala
- [x] `POST /login-validate` - autenticazione validatore
- [x] `GET /info-room` - info sala + **server_start_time** per invalidare sessioni
- [x] `POST /login-validate` - verifica solo password validatore
- [x] `GET /anagrafica/{badge_code}` - ricerca utente
- [x] Pulizia caratteri sentinel dal badge
- [x] Confronto con e senza zeri iniziali
@@ -73,27 +76,25 @@ Il frontend gestirà autonomamente la visualizzazione internazionale con carosel
- [x] Verifica password validatore
- [x] Verifica utente ammesso
- [x] Risposta asettica (solo success + message)
- [ ] **DA FARE:** Spostare routes in file dedicato
- [x] 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:
- [x] 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`
- [x] Caricamento dinamico dati da JSON
- [x] Import routes da modulo `api`
### 6. Struttura Base FastAPI
### 6. Invalidazione Sessioni
- [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
- [x] `SERVER_START_TIME` generato all'avvio del server
- [x] Restituito in `/info-room` per permettere al frontend di invalidare sessioni vecchie
- [x] Se il server riparte, tutte le sessioni frontend vengono invalidate
---
@@ -101,19 +102,16 @@ Il frontend gestirà autonomamente la visualizzazione internazionale con carosel
```json
{
"validator": {
"badge": "999999",
"password": "focolari"
},
"validator_password": "focolari",
"room": {
"room_name": "Sala Assemblea",
"meeting_id": "VOT-2024"
},
"users": [
{
"badge_code": "000001",
"nome": "Maria",
"cognome": "Rossi",
"badge_code": "0008988288",
"nome": "Marco",
"cognome": "Bianchi",
"url_foto": "https://...",
"ruolo": "Votante",
"ammesso": true
@@ -126,25 +124,22 @@ Il frontend gestirà autonomamente la visualizzazione internazionale con carosel
## Comandi Esecuzione
### Avvio Standard
### Via Script (consigliato)
```bash
./dev.sh server # Avvia server con frontend
./dev.sh server -p 9000 # Porta custom
./dev.sh backend # Solo API, no frontend
```
### Manuale
```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
@@ -153,29 +148,28 @@ pipenv run python main.py -p 9000 -d data/users_test.json
# Health check
curl http://localhost:8000/
# Info sala
# Info sala (include server_start_time)
curl http://localhost:8000/info-room
# Ricerca utente
curl http://localhost:8000/anagrafica/000001
# Ricerca utente reale
curl http://localhost:8000/anagrafica/0008988288
# Badge non trovato
curl http://localhost:8000/anagrafica/0006478281
# Login validatore
curl -X POST http://localhost:8000/login-validate \
-H "Content-Type: application/json" \
-d '{"badge": "999999", "password": "focolari"}'
-d '{"badge": "qualsiasi", "password": "focolari"}'
# Richiesta ingresso (risposta asettica)
# Richiesta ingresso
curl -X POST http://localhost:8000/entry-request \
-H "Content-Type: application/json" \
-d '{"user_badge": "000001", "validator_password": "focolari"}'
-d '{"user_badge": "0008988288", "validator_password": "focolari"}'
```
---
## Note Implementative
## ✅ BACKEND COMPLETATO
- 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
Tutti i task sono stati implementati e testati.

View File

@@ -3,7 +3,7 @@
## 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.
Ottimizzata per tablet in orizzontale.
---
@@ -14,8 +14,11 @@ Include sistema di test automatici per validazione e regression detection.
- [x] Inizializzare Vite + React + TypeScript
- [x] Installare Tailwind CSS 4
- [x] Configurare `tsconfig.json`
- [x] Configurare `vite.config.ts`
- [x] Configurare `vite.config.ts` con proxy API
- [x] Configurare `.gitignore`
- [x] Path relativi per build (base: './')
- [x] Output build in `frontend/dist/`
- [x] Favicon con logo Focolari
### 2. Design System
@@ -29,15 +32,14 @@ Include sistema di test automatici per validazione e regression detection.
### 3. Tipi TypeScript (`types/index.ts`)
- [x] `RoomInfo` - info sala
- [x] `RoomInfo` - info sala + **server_start_time**
- [x] `User` - dati utente
- [x] `LoginRequest/Response`
- [x] `EntryRequest/Response` (SENZA welcome_message)
- [x] `AppState` - stati applicazione
- [x] `ValidatorSession` - sessione validatore
- [x] `ValidatorSession` - sessione validatore + **serverStartTime**
- [x] `RFIDScannerState` - stato scanner
- [x] `RFIDScanResult` - risultato scan
- [ ] **DA FARE:** Aggiornare `EntryResponse` rimuovendo `welcome_message`
### 4. API Service (`services/api.ts`)
@@ -47,259 +49,90 @@ Include sistema di test automatici per validazione e regression detection.
- [x] `loginValidator()` - POST /login-validate
- [x] `getUserByBadge()` - GET /anagrafica/{badge}
- [x] `requestEntry()` - POST /entry-request
- [ ] **DA FARE:** Logging con prefisso `[API]`
- [x] Logging con prefisso `[API]`
- [x] Path relativi (proxy Vite in dev, stesso server in prod)
### 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
- [x] Supporto pattern multipli (US: `;?`, IT: `ò_`)
- [x] Rilevamento automatico pattern in uso
- [x] Gestione Enter post-completamento
- [x] Timeout 2.5s per scansioni accidentali
- [x] ESC annulla scansione in corso
- [x] Logging avanzato con prefisso `[RFID]`
- [x] Export `keyLog` per debug
### 6. Componenti UI (`components/`)
- [x] `Logo.tsx` - logo Focolari
- [x] `Button.tsx` - varianti primary/secondary/danger
- [x] `Input.tsx` - campo input styled
- [x] `Input.tsx` - campo input styled + **toggle password visibility**
- [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
- [x] `WelcomeCarousel.tsx` - carosello messaggi multilingua
- [x] `NumLockBanner.tsx` - avviso NumLock per desktop
### 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] `LoadingScreen.tsx` - caricamento iniziale + ping automatico
- [x] `ValidatorLoginScreen.tsx` - attesa badge + password + NumLockBanner
- [x] `ActiveGateScreen.tsx` - varco attivo:
- [x] Card utente (layout largo per tablet)
- [x] **Schermata "badge non trovato"** con countdown 30s
- [x] **Notifica badge validatore ignorato**
- [x] NumLockBanner
- [x] `SuccessModal.tsx` - conferma ingresso con carosello
- [x] `ErrorModal.tsx` - errore fullscreen
- [x] `index.ts` - barrel export
- [ ] **DA FARE:** `DebugScreen.tsx` - pagina diagnostica RFID
- [x] `DebugScreen.tsx` - pagina diagnostica RFID
### 8. State Machine (`App.tsx`)
- [x] Stati applicazione gestiti
- [x] Integrazione `useRFIDScanner`
- [x] Gestione sessione validatore (localStorage)
- [x] **Qualsiasi badge può essere validatore** (verificato con password)
- [x] Password salvata in sessione per conferme ingresso
- [x] **Invalidazione sessione se server riparte** (serverStartTime)
- [x] Timeout sessione 30 minuti
- [x] Timeout utente 60 secondi
- [x] **Timeout badge non trovato 30 secondi**
- [x] Cambio rapido badge partecipante
- [x] Conferma con badge validatore
- [ ] **DA FARE:** Logging transizioni con prefisso `[FLOW]`
- [x] Conferma con badge validatore (quello della sessione)
- [x] **Notifica se badge validatore rippassato senza utente**
- [x] 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
- [x] Componente `WelcomeCarousel.tsx`
- [x] 10 lingue supportate
- [x] Scorrimento automatico ogni 800ms
- [x] Modale fullscreen verde
- [x] Durata totale: 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
- [x] Pagina `/debug` dedicata
- [x] Logging console strutturato `[RFID]`, `[FLOW]`, `[API]`
### 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"
}
}
```
- [x] React Router
- [x] Route principale `/`
- [x] Route debug `/debug`
---
## Correzioni Necessarie
## Badge di Test
### 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.
| Badge | Nome | Ruolo | Ammesso |
|--------------|----------------|---------|---------------|
| `0008988288` | Marco Bianchi | Votante | ✅ Sì |
| `0007399575` | Laura Rossi | Votante | ✅ Sì |
| `0000514162` | Giuseppe Verdi | Tecnico | ❌ No |
| `0006478281` | - | - | ⚠️ Non nel DB |
---
## 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
## ✅ FRONTEND COMPLETATO

View File

@@ -28,3 +28,5 @@ Thumbs.db
# Local environment
.env
.env.local

View File

@@ -4,14 +4,14 @@ verify_ssl = true
name = "pypi"
[packages]
fastapi = ">=0.109.0"
uvicorn = {extras = ["standard"], version = ">=0.27.0"}
pydantic = ">=2.5.0"
fastapi = "*"
uvicorn = "*"
pydantic = "*"
[dev-packages]
[requires]
python_version = "3.10"
python_version = "3.14"
[scripts]
start = "python main.py"

View File

@@ -0,0 +1,7 @@
"""
Focolari Voting System - API Package
"""
from .routes import router, init_data
__all__ = ["router", "init_data"]

153
backend-mock/api/routes.py Normal file
View File

@@ -0,0 +1,153 @@
"""
Focolari Voting System - API Routes
"""
import time
from fastapi import APIRouter, HTTPException
from schemas import (
LoginRequest,
EntryRequest,
UserResponse,
RoomInfoResponse,
LoginResponse,
EntryResponse,
)
router = APIRouter()
# Timestamp di avvio del server (per invalidare sessioni frontend)
SERVER_START_TIME = int(time.time() * 1000)
# Dati caricati dinamicamente dal main
_data = {
"validator_password": "",
"room": {"room_name": "", "meeting_id": ""},
"users": []
}
# Caratteri sentinel da pulire (tutti i layout supportati)
SENTINEL_CHARS = [";", "?", "ò", "_", "ç", "+"]
def init_data(data: dict):
"""Inizializza i dati caricati dal file JSON"""
global _data
_data = data
def clean_badge(badge: str) -> str:
"""Rimuove i caratteri sentinel dal badge"""
clean = badge.strip()
for char in SENTINEL_CHARS:
clean = clean.replace(char, "")
return clean
def find_user(badge_code: str) -> dict | None:
"""Cerca un utente per badge code"""
clean = clean_badge(badge_code)
for user in _data["users"]:
if user["badge_code"] == clean or user["badge_code"].lstrip("0") == clean.lstrip("0"):
return user
return None
@router.get("/info-room", response_model=RoomInfoResponse)
async def get_room_info():
"""Restituisce le informazioni sulla sala e la riunione corrente."""
return RoomInfoResponse(
room_name=_data["room"]["room_name"],
meeting_id=_data["room"]["meeting_id"],
server_start_time=SERVER_START_TIME
)
@router.post("/login-validate", response_model=LoginResponse)
async def login_validate(request: LoginRequest):
"""
Valida la password del validatore.
Il badge viene passato dal frontend ma attualmente non viene verificato
lato server - serve solo per essere memorizzato nella sessione frontend.
TODO: Concordare con committenti se il badge debba essere verificato
anche lato server (es. lista badge validatori autorizzati).
"""
if request.password != _data["validator_password"]:
raise HTTPException(
status_code=401,
detail="Password non corretta"
)
# Il badge viene restituito per conferma, ma non è validato lato server
clean = clean_badge(request.badge) if request.badge else "unknown"
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=f"focolare-token-{clean}"
)
@router.get("/anagrafica/{badge_code}", response_model=UserResponse)
async def get_user_anagrafica(badge_code: str):
"""
Cerca un utente tramite il suo badge code.
"""
user = find_user(badge_code)
if not user:
clean = clean_badge(badge_code)
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean} non trovato nel sistema"
)
response = UserResponse(
badge_code=user["badge_code"],
nome=user["nome"],
cognome=user["cognome"],
url_foto=user["url_foto"],
ruolo=user["ruolo"],
ammesso=user["ammesso"]
)
if not user["ammesso"]:
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!"
return response
@router.post("/entry-request", response_model=EntryResponse)
async def process_entry_request(request: EntryRequest):
"""
Processa una richiesta di ingresso.
Risposta asettica senza messaggi multilingua.
"""
if request.validator_password != _data["validator_password"]:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
user = find_user(request.user_badge)
if not user:
clean = clean_badge(request.user_badge)
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean} non trovato"
)
if not user["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user['nome']} {user['cognome']} NON è autorizzato all'ingresso"
)
return EntryResponse(
success=True,
message=f"Ingresso registrato per {user['nome']} {user['cognome']}"
)

View File

@@ -0,0 +1,33 @@
{
"validator_password": "focolari",
"room": {
"room_name": "Sala Assemblea",
"meeting_id": "VOT-2024"
},
"users": [
{
"badge_code": "0008988288",
"nome": "Marco",
"cognome": "Bianchi",
"url_foto": "https://randomuser.me/api/portraits/men/1.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "0007399575",
"nome": "Laura",
"cognome": "Rossi",
"url_foto": "https://randomuser.me/api/portraits/women/2.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "0000514162",
"nome": "Giuseppe",
"cognome": "Verdi",
"url_foto": "https://randomuser.me/api/portraits/men/3.jpg",
"ruolo": "Tecnico",
"ammesso": false
}
]
}

View File

@@ -0,0 +1,33 @@
{
"validator_password": "test123",
"room": {
"room_name": "Sala Test",
"meeting_id": "TEST-001"
},
"users": [
{
"badge_code": "111111",
"nome": "Test",
"cognome": "Ammesso",
"url_foto": "https://randomuser.me/api/portraits/lego/1.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "222222",
"nome": "Test",
"cognome": "NonAmmesso",
"url_foto": "https://randomuser.me/api/portraits/lego/2.jpg",
"ruolo": "Ospite",
"ammesso": false
},
{
"badge_code": "333333",
"nome": "Test",
"cognome": "Tecnico",
"url_foto": "https://randomuser.me/api/portraits/lego/3.jpg",
"ruolo": "Tecnico",
"ammesso": true
}
]
}

View File

@@ -1,21 +1,72 @@
"""
Focolari Voting System - Backend Mock
Sistema di controllo accessi per votazioni del Movimento dei Focolari
Utilizzo:
python main.py # Default: porta 8000, dati default
python main.py -p 9000 # Porta custom
python main.py -d data/users_test.json # Dataset custom
python main.py -p 9000 -d data/users_test.json
"""
import random
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import argparse
import json
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from api.routes import router, init_data
# Configurazione default
DEFAULT_PORT = 8000
DEFAULT_HOST = "0.0.0.0"
DEFAULT_DATA = "data/users_default.json"
STATIC_DIR = Path(__file__).parent.parent / "frontend" / "dist"
def load_data(data_path: str) -> dict:
"""Carica i dati dal file JSON"""
path = Path(data_path)
if not path.is_absolute():
# Path relativo alla directory del main.py
base_dir = Path(__file__).parent
path = base_dir / path
if not path.exists():
print(f"❌ Errore: File dati non trovato: {path}")
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"📂 Dati caricati da: {path}")
print(f" - Password validatore: {'*' * len(data['validator_password'])}")
print(f" - Sala: {data['room']['room_name']}")
print(f" - Utenti: {len(data['users'])}")
return data
except json.JSONDecodeError as e:
print(f"❌ Errore parsing JSON: {e}")
sys.exit(1)
except KeyError as e:
print(f"❌ Errore struttura JSON: chiave mancante {e}")
sys.exit(1)
def create_app(data: dict, serve_frontend: bool = True) -> FastAPI:
"""Crea e configura l'applicazione FastAPI"""
app = FastAPI(
title="Focolari Voting System API",
description="Backend mock per il sistema di controllo accessi",
version="1.0.0"
)
# CORS abilitato per tutti
# CORS abilitato per tutti (utile in sviluppo)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -24,254 +75,117 @@ app.add_middleware(
allow_headers=["*"],
)
# ============================================
# MODELLI PYDANTIC
# ============================================
# Inizializza i dati nelle routes
init_data(data)
class LoginRequest(BaseModel):
badge: str
password: str
# Registra le routes API
app.include_router(router)
class EntryRequest(BaseModel):
user_badge: str
validator_password: str
# Serve frontend statico se la cartella esiste
if serve_frontend and STATIC_DIR.exists():
print(f"🌐 Frontend statico servito da: {STATIC_DIR}")
class UserResponse(BaseModel):
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: str
ammesso: bool
warning: Optional[str] = None
# Serve index.html per la root e tutte le route SPA
@app.get("/")
async def serve_index():
return FileResponse(STATIC_DIR / "index.html")
class RoomInfoResponse(BaseModel):
room_name: str
meeting_id: str
@app.get("/debug")
async def serve_debug():
return FileResponse(STATIC_DIR / "index.html")
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
# ============================================
# Monta i file statici (JS, CSS, assets) - DEVE essere dopo le route
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# Fallback per altri file statici nella root (favicon, ecc.)
@app.get("/{filename:path}")
async def serve_static(filename: str):
file_path = STATIC_DIR / filename
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Per route SPA sconosciute, serve index.html
return FileResponse(STATIC_DIR / "index.html")
else:
# API-only mode
@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"}
return {
"status": "ok",
"message": "Focolari Voting System API is running",
"room": data["room"]["room_name"],
"frontend": "not built - run 'npm run build' in frontend/"
}
return app
@app.get("/info-room", response_model=RoomInfoResponse)
async def get_room_info():
def parse_args():
"""Parse degli argomenti da linea di comando"""
parser = argparse.ArgumentParser(
description="Focolari Voting System - Backend Mock Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Esempi:
python main.py # Avvio standard
python main.py -p 9000 # Porta 9000
python main.py -d data/users_test.json # Dataset test
python main.py --host 127.0.0.1 -p 8080 # Solo localhost
python main.py --api-only # Solo API, no frontend
"""
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"
parser.add_argument(
"-p", "--port",
type=int,
default=DEFAULT_PORT,
help=f"Porta del server (default: {DEFAULT_PORT})"
)
if request.password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password non corretta"
parser.add_argument(
"-d", "--data",
type=str,
default=DEFAULT_DATA,
help=f"Path al file JSON con i dati (default: {DEFAULT_DATA})"
)
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=MOCK_TOKEN
parser.add_argument(
"--host",
type=str,
default=DEFAULT_HOST,
help=f"Host di binding (default: {DEFAULT_HOST})"
)
@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"]
parser.add_argument(
"--api-only",
action="store_true",
help="Avvia solo le API senza servire il frontend"
)
# 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"
)
return parser.parse_args()
@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("?", "")
def main():
"""Entry point principale"""
args = parse_args()
# Verifica password validatore
if request.validator_password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
print("🚀 Avvio Focolari Voting System Backend...")
print("=" * 50)
# 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
# Carica i dati
data = load_data(args.data)
if not user_found:
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_user_badge} non trovato"
)
print("=" * 50)
print(f"📍 Server in ascolto su http://{args.host}:{args.port}")
print(f"📚 Documentazione API su http://{args.host}:{args.port}/docs")
if not args.api_only and STATIC_DIR.exists():
print(f"🌐 Frontend disponibile su http://{args.host}:{args.port}/")
print("=" * 50)
if not user_found["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user_found['nome']} {user_found['cognome']} NON è autorizzato all'ingresso"
)
# Crea e avvia l'app
app = create_app(data, serve_frontend=not args.api_only)
uvicorn.run(app, host=args.host, port=args.port)
# 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)
main()

View File

@@ -0,0 +1,21 @@
"""
Focolari Voting System - Schemas Package
"""
from .models import (
LoginRequest,
EntryRequest,
UserResponse,
RoomInfoResponse,
LoginResponse,
EntryResponse,
)
__all__ = [
"LoginRequest",
"EntryRequest",
"UserResponse",
"RoomInfoResponse",
"LoginResponse",
"EntryResponse",
]

View File

@@ -0,0 +1,50 @@
"""
Focolari Voting System - Modelli Pydantic
"""
from typing import Optional, Literal
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Richiesta login validatore"""
badge: str
password: str
class EntryRequest(BaseModel):
"""Richiesta ingresso partecipante"""
user_badge: str
validator_password: str
class UserResponse(BaseModel):
"""Risposta dati utente"""
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: Literal["Tecnico", "Votante", "Ospite"]
ammesso: bool
warning: Optional[str] = None
class RoomInfoResponse(BaseModel):
"""Risposta info sala"""
room_name: str
meeting_id: str
server_start_time: int # Timestamp avvio server per invalidare sessioni
class LoginResponse(BaseModel):
"""Risposta login"""
success: bool
message: str
token: Optional[str] = None
class EntryResponse(BaseModel):
"""Risposta richiesta ingresso (asettica, senza welcome_message)"""
success: bool
message: str

258
dev.sh Executable file
View File

@@ -0,0 +1,258 @@
#!/bin/bash
# ============================================
# Focolari Voting System - Script di Sviluppo
# ============================================
#
# Utilizzo:
# ./dev.sh install - Installa dipendenze frontend e backend
# ./dev.sh dev - Avvia frontend (dev) + backend (api-only)
# ./dev.sh build - Builda il frontend
# ./dev.sh server - Builda frontend (se necessario) e avvia server completo
# ./dev.sh backend - Avvia solo il backend (api-only)
# ./dev.sh frontend - Avvia solo il frontend in dev mode
# ./dev.sh shell - Apre shell pipenv del backend
# ./dev.sh clean - Pulisce build e cache
# ./dev.sh help - Mostra questo messaggio
#
set -e
# Directory del progetto
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$SCRIPT_DIR/backend-mock"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
FRONTEND_DIST="$FRONTEND_DIR/dist"
# Colori per output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Funzioni di utilità
info() {
echo -e "${BLUE} $1${NC}"
}
success() {
echo -e "${GREEN}$1${NC}"
}
warn() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
error() {
echo -e "${RED}$1${NC}"
exit 1
}
# Verifica prerequisiti
check_prereqs() {
command -v node >/dev/null 2>&1 || error "Node.js non trovato. Installalo prima."
command -v npm >/dev/null 2>&1 || error "npm non trovato. Installalo prima."
command -v python3 >/dev/null 2>&1 || error "Python3 non trovato. Installalo prima."
command -v pipenv >/dev/null 2>&1 || error "pipenv non trovato. Installa con: pip install pipenv"
}
# Installa dipendenze
cmd_install() {
info "Installazione dipendenze..."
check_prereqs
# Backend
info "Installazione dipendenze backend (pipenv)..."
cd "$BACKEND_DIR"
pipenv install
success "Backend installato"
# Frontend
info "Installazione dipendenze frontend (npm)..."
cd "$FRONTEND_DIR"
npm install
success "Frontend installato"
success "Tutte le dipendenze installate!"
}
# Build frontend
cmd_build() {
info "Build frontend..."
cd "$FRONTEND_DIR"
npm run build
success "Frontend buildato in $FRONTEND_DIST"
}
# Verifica se serve rebuild
needs_rebuild() {
# Se dist non esiste, serve build
if [ ! -d "$FRONTEND_DIST" ]; then
return 0
fi
# Se dist/index.html non esiste, serve build
if [ ! -f "$FRONTEND_DIST/index.html" ]; then
return 0
fi
# Controlla se ci sono file frontend più recenti della build
local latest_src=$(find "$FRONTEND_DIR/src" -type f -newer "$FRONTEND_DIST/index.html" 2>/dev/null | head -1)
if [ -n "$latest_src" ]; then
return 0
fi
# Controlla anche index.html e config
if [ "$FRONTEND_DIR/index.html" -nt "$FRONTEND_DIST/index.html" ]; then
return 0
fi
if [ "$FRONTEND_DIR/vite.config.ts" -nt "$FRONTEND_DIST/index.html" ]; then
return 0
fi
return 1
}
# Server completo (build + backend)
cmd_server() {
check_prereqs
# Rebuild se necessario
if needs_rebuild; then
warn "Rilevati cambiamenti nel frontend, rebuild in corso..."
cmd_build
else
info "Frontend già aggiornato, skip build"
fi
# Avvia backend con frontend
info "Avvio server completo..."
cd "$BACKEND_DIR"
pipenv run python main.py "$@"
}
# Solo backend
cmd_backend() {
check_prereqs
info "Avvio backend (API only)..."
cd "$BACKEND_DIR"
pipenv run python main.py --api-only "$@"
}
# Solo frontend dev
cmd_frontend() {
check_prereqs
info "Avvio frontend in dev mode..."
cd "$FRONTEND_DIR"
npm run dev
}
# Dev mode: backend api-only + frontend dev (in parallelo)
cmd_dev() {
check_prereqs
info "Avvio ambiente di sviluppo..."
info "Backend API: http://localhost:8000"
info "Frontend Dev: http://localhost:5173"
echo ""
# Avvia backend in background
cd "$BACKEND_DIR"
pipenv run python main.py --api-only &
BACKEND_PID=$!
# Trap per cleanup
trap "kill $BACKEND_PID 2>/dev/null" EXIT
# Avvia frontend
cd "$FRONTEND_DIR"
npm run dev
}
# Shell pipenv
cmd_shell() {
info "Apertura shell pipenv backend..."
cd "$BACKEND_DIR"
pipenv shell
}
# Pulizia
cmd_clean() {
info "Pulizia build e cache..."
# Frontend
rm -rf "$FRONTEND_DIR/node_modules/.vite"
rm -rf "$FRONTEND_DIST"
# Backend
find "$BACKEND_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "$BACKEND_DIR" -type f -name "*.pyc" -delete 2>/dev/null || true
success "Pulizia completata"
}
# Help
cmd_help() {
echo "============================================"
echo "Focolari Voting System - Script di Sviluppo"
echo "============================================"
echo ""
echo "Utilizzo: ./dev.sh <comando> [opzioni]"
echo ""
echo "Comandi disponibili:"
echo " install Installa dipendenze frontend e backend"
echo " dev Avvia frontend (dev) + backend (api-only) in parallelo"
echo " build Builda il frontend per produzione"
echo " server Builda frontend (se cambiato) e avvia server completo"
echo " backend Avvia solo il backend (api-only)"
echo " frontend Avvia solo il frontend in dev mode"
echo " shell Apre shell pipenv del backend"
echo " clean Pulisce build e cache"
echo " help Mostra questo messaggio"
echo ""
echo "Esempi:"
echo " ./dev.sh install # Setup iniziale"
echo " ./dev.sh dev # Sviluppo (hot reload)"
echo " ./dev.sh server # Produzione locale"
echo " ./dev.sh server -p 9000 # Server su porta 9000"
echo " ./dev.sh server -d data/users_test.json # Con dataset test"
echo ""
}
# Main
case "${1:-help}" in
install)
cmd_install
;;
build)
cmd_build
;;
server)
shift
cmd_server "$@"
;;
backend)
shift
cmd_backend "$@"
;;
frontend)
cmd_frontend
;;
dev)
cmd_dev
;;
shell)
cmd_shell
;;
clean)
cmd_clean
;;
help|--help|-h)
cmd_help
;;
*)
error "Comando sconosciuto: $1. Usa './dev.sh help' per la lista comandi."
;;
esac

View File

@@ -4,12 +4,16 @@ This template provides a minimal setup to get React working in Vite with HMR and
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
- [@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).
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
@@ -43,7 +47,10 @@ export default defineConfig([
])
```
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:
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

View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="it">
<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>
<link href="./favicon.jpg" rel="icon" type="image/jpeg"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Focolari Voting System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="/src/main.tsx" type="module"></script>
</body>
</html>

View File

@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2265,6 +2266,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3496,6 +3510,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3567,6 +3619,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

BIN
frontend/public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -3,27 +3,13 @@
* State Machine per il controllo accessi
*/
import { useState, useEffect, useCallback } from 'react';
import {useCallback, useEffect, useState} 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 {ActiveGateScreen, ErrorModal, LoadingScreen, SuccessModal, ValidatorLoginScreen,} from './screens';
import {ApiError, getRoomInfo, getUserByBadge, loginValidator, requestEntry,} from './services/api';
import type {AppState, RoomInfo, User, ValidatorSession} from './types';
// Costanti
const VALIDATOR_BADGE = '999999';
const VALIDATOR_PASSWORD = 'focolari';
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti
const USER_TIMEOUT_SECONDS = 60;
const STORAGE_KEY = 'focolari_validator_session';
@@ -42,10 +28,16 @@ function App() {
// Modal states
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [successUserName, setSuccessUserName] = useState<string | undefined>(undefined);
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorModalMessage, setErrorModalMessage] = useState('');
// Notifica badge validatore ignorato
const [showValidatorBadgeNotice, setShowValidatorBadgeNotice] = useState(false);
// Badge non trovato (con timeout per tornare all'attesa)
const [notFoundBadge, setNotFoundBadge] = useState<string | null>(null);
// ============================================
// Session Management
// ============================================
@@ -63,7 +55,7 @@ function App() {
setAppState('waiting-validator');
}, []);
const loadSession = useCallback((): ValidatorSession | null => {
const loadSession = useCallback((serverStartTime?: number): ValidatorSession | null => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
@@ -72,6 +64,14 @@ function App() {
// Check if session is expired
if (Date.now() > session.expiresAt) {
console.log('[FLOW] Session expired by time');
localStorage.removeItem(STORAGE_KEY);
return null;
}
// Check if server restarted (invalidate old sessions)
if (serverStartTime && session.serverStartTime !== serverStartTime) {
console.log('[FLOW] Session invalidated - server restarted');
localStorage.removeItem(STORAGE_KEY);
return null;
}
@@ -87,35 +87,43 @@ function App() {
// ============================================
const handleRFIDScan = useCallback(async (code: string) => {
console.log('[App] Badge scansionato:', code);
console.log('[RFID] Badge scansionato:', code);
// Pulisci il codice
const cleanCode = code.trim();
switch (appState) {
case 'waiting-validator':
// Verifica se è un badge validatore
if (cleanCode === VALIDATOR_BADGE) {
// Qualsiasi badge può essere un validatore - verrà verificato con la password
console.log('[FLOW] Transition: waiting-validator -> validator-password');
setPendingValidatorBadge(cleanCode);
setAppState('validator-password');
} else {
setError('Badge validatore non riconosciuto');
setTimeout(() => setError(undefined), 3000);
}
break;
case 'validator-password':
// Ignora badge durante inserimento password
console.log('[FLOW] Badge ignorato durante inserimento password');
break;
case 'gate-active':
case 'showing-user':
// Se è il badge del validatore e c'è un utente a schermo
if (cleanCode === VALIDATOR_BADGE && currentUser && currentUser.ammesso) {
// Se è il badge del validatore attuale
if (validatorSession && cleanCode === validatorSession.badge) {
if (currentUser && currentUser.ammesso) {
console.log('[FLOW] Validator badge detected - confirming entry');
// Conferma ingresso
await handleEntryConfirm();
} else if (cleanCode !== VALIDATOR_BADGE) {
// Nuovo badge partecipante - carica utente
} else {
// Badge validatore passato senza utente ammesso - mostra notifica
console.log('[FLOW] Validator badge ignored - showing notice');
setShowValidatorBadgeNotice(true);
setTimeout(() => setShowValidatorBadgeNotice(false), 4000);
}
} else {
console.log('[FLOW] Loading participant:', cleanCode);
// Badge partecipante - carica utente
// Se c'era un badge non trovato, cancellalo e carica il nuovo
setNotFoundBadge(null);
await handleLoadUser(cleanCode);
}
break;
@@ -123,7 +131,7 @@ function App() {
default:
break;
}
}, [appState, currentUser]);
}, [appState, currentUser, validatorSession]);
// ============================================
// Initialize RFID Scanner
@@ -144,17 +152,25 @@ function App() {
const handleLoadUser = useCallback(async (badgeCode: string) => {
setLoading(true);
setError(undefined);
setNotFoundBadge(null);
setAppState('showing-user');
try {
const user = await getUserByBadge(badgeCode);
setCurrentUser(user);
} catch (err) {
if (err instanceof ApiError && err.statusCode === 404) {
// Utente non trovato - mostra schermata speciale con timeout
console.log('[FLOW] Badge not found:', badgeCode);
setNotFoundBadge(badgeCode);
setCurrentUser(null);
} else {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante il caricamento utente';
setError(message);
setCurrentUser(null);
}
} finally {
setLoading(false);
}
@@ -165,14 +181,18 @@ function App() {
setLoading(true);
// Salva il nome utente prima di pulirlo
const userName = `${currentUser.nome} ${currentUser.cognome}`;
try {
const response = await requestEntry(
currentUser.badge_code,
VALIDATOR_PASSWORD
validatorSession.password // Uso la password dalla sessione
);
if (response.success) {
setSuccessMessage(response.welcome_message || 'Benvenuto!');
console.log('[FLOW] Entry confirmed for:', userName);
setSuccessUserName(userName);
setShowSuccessModal(true);
setCurrentUser(null);
setAppState('gate-active');
@@ -189,7 +209,7 @@ function App() {
}, [currentUser, validatorSession]);
const handlePasswordSubmit = useCallback(async (password: string) => {
if (!pendingValidatorBadge) return;
if (!pendingValidatorBadge || !roomInfo) return;
setLoading(true);
setError(undefined);
@@ -198,11 +218,14 @@ function App() {
const response = await loginValidator(pendingValidatorBadge, password);
if (response.success) {
console.log('[FLOW] Validator authenticated, badge:', pendingValidatorBadge);
const session: ValidatorSession = {
badge: pendingValidatorBadge,
password: password, // Salvo la password per le conferme ingresso
token: response.token || '',
loginTime: Date.now(),
expiresAt: Date.now() + SESSION_DURATION_MS,
serverStartTime: roomInfo.server_start_time, // Per invalidare se server riparte
};
saveSession(session);
@@ -217,7 +240,7 @@ function App() {
} finally {
setLoading(false);
}
}, [pendingValidatorBadge, saveSession]);
}, [pendingValidatorBadge, saveSession, roomInfo]);
// ============================================
// UI Handlers
@@ -231,6 +254,7 @@ function App() {
const handleCancelUser = useCallback(() => {
setCurrentUser(null);
setNotFoundBadge(null);
setError(undefined);
setAppState('gate-active');
}, []);
@@ -238,13 +262,14 @@ function App() {
const handleUserTimeout = useCallback(() => {
console.log('[App] User timeout - tornando in attesa');
setCurrentUser(null);
setNotFoundBadge(null);
setError(undefined);
setAppState('gate-active');
}, []);
const handleSuccessModalClose = useCallback(() => {
setShowSuccessModal(false);
setSuccessMessage('');
setSuccessUserName(undefined);
}, []);
const handleErrorModalClose = useCallback(() => {
@@ -263,8 +288,8 @@ function App() {
const info = await getRoomInfo();
setRoomInfo(info);
// Verifica sessione esistente
const existingSession = loadSession();
// Verifica sessione esistente (passa serverStartTime per invalidazione)
const existingSession = loadSession(info.server_start_time);
if (existingSession) {
setValidatorSession(existingSession);
@@ -347,20 +372,21 @@ function App() {
rfidState={rfidState}
rfidBuffer={rfidBuffer}
currentUser={currentUser}
notFoundBadge={notFoundBadge}
loading={loading}
error={error}
onCancelUser={handleCancelUser}
onLogout={clearSession}
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
onUserTimeout={handleUserTimeout}
showValidatorBadgeNotice={showValidatorBadgeNotice}
/>
{/* Success Modal */}
<SuccessModal
isOpen={showSuccessModal}
onClose={handleSuccessModalClose}
welcomeMessage={successMessage}
userName={currentUser ? `${currentUser.nome} ${currentUser.cognome}` : undefined}
userName={successUserName}
/>
{/* Error Modal */}

View File

@@ -2,7 +2,7 @@
* Countdown Timer Component - Focolari Voting System
*/
import { useState, useEffect, useCallback } from 'react';
import {useCallback, useEffect, useState} from 'react';
interface CountdownTimerProps {
/** Secondi totali */

View File

@@ -2,7 +2,7 @@
* Input Component - Focolari Voting System
*/
import { forwardRef } from 'react';
import {forwardRef, useState} from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
@@ -11,8 +11,12 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, fullWidth = true, className = '', ...props }, ref) => {
({label, error, fullWidth = true, className = '', type, ...props}, ref) => {
const widthClass = fullWidth ? 'w-full' : '';
const isPassword = type === 'password';
const [showPassword, setShowPassword] = useState(false);
const inputType = isPassword && showPassword ? 'text' : type;
return (
<div className={`${widthClass} ${className}`}>
@@ -21,13 +25,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{label}
</label>
)}
<div className="relative">
<input
ref={ref}
type={inputType}
className={`
w-full px-4 py-3 text-lg
border-2 rounded-xl
transition-all duration-200
focus:outline-none focus:ring-4 focus:ring-focolare-blue/30
${isPassword ? 'pr-12' : ''}
${error
? 'border-error focus:border-error'
: 'border-gray-300 focus:border-focolare-blue'
@@ -35,6 +42,32 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
`.trim()}
{...props}
/>
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-700 transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Nascondi password' : 'Mostra password'}
>
{showPassword ? (
// Icona occhio barrato (nascondi)
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
) : (
// Icona occhio (mostra)
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
)}
</button>
)}
</div>
{error && (
<p className="mt-2 text-sm text-error font-medium">{error}</p>
)}

View File

@@ -0,0 +1,132 @@
/**
* NumLock Banner Component - Focolari Voting System
*
* Mostra un banner di avviso su browser desktop quando NumLock
* potrebbe essere necessario per il corretto funzionamento del lettore RFID.
* Non viene mostrato su dispositivi touch (tablet, smartphone).
*/
import {useEffect, useState} from 'react';
interface NumLockBannerProps {
className?: string;
}
export function NumLockBanner({className = ''}: NumLockBannerProps) {
const [isDesktop, setIsDesktop] = useState(false);
const [numLockState, setNumLockState] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(false);
// Rileva se siamo su desktop (non touch device)
useEffect(() => {
const checkIfDesktop = () => {
// Controlla se è un dispositivo touch
const isTouchDevice =
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
// @ts-expect-error - msMaxTouchPoints è specifico di IE/Edge
navigator.msMaxTouchPoints > 0;
// Controlla user agent per mobile/tablet
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
const isMobileUA = mobileRegex.test(navigator.userAgent);
// È desktop solo se non è touch E non ha UA mobile
setIsDesktop(!isTouchDevice && !isMobileUA);
};
checkIfDesktop();
}, []);
// Ascolta eventi tastiera per rilevare stato NumLock
useEffect(() => {
if (!isDesktop) return;
const handleKeyDown = (event: KeyboardEvent) => {
// getModifierState restituisce lo stato di NumLock
const numLock = event.getModifierState('NumLock');
setNumLockState(numLock);
};
// Aggiungi listener
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isDesktop]);
// Non mostrare su mobile/tablet o se dismissato
if (!isDesktop || dismissed) {
return null;
}
return (
<div className={`bg-amber-100 border border-amber-300 rounded-lg p-4 ${className}`}>
<div className="flex items-start gap-3">
{/* Icona */}
<div className="flex-shrink-0 mt-0.5">
<svg
className="w-5 h-5 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
{/* Contenuto */}
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">
Modalità Desktop Rilevata
</h4>
<p className="text-sm text-amber-700 mt-1">
Per il corretto funzionamento del lettore RFID, assicurati che il tasto
<strong> Bloc Num (NumLock) </strong> sia attivo.
</p>
{/* Stato NumLock */}
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-amber-700">Stato attuale:</span>
{numLockState === null ? (
<span className="text-sm text-amber-600 italic">
Premi un tasto per rilevare...
</span>
) : numLockState ? (
<span
className="inline-flex items-center gap-1 text-sm font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
NumLock ATTIVO
</span>
) : (
<span
className="inline-flex items-center gap-1 text-sm font-medium text-red-700 bg-red-100 px-2 py-0.5 rounded">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
NumLock DISATTIVO
</span>
)}
</div>
</div>
{/* Pulsante chiudi */}
<button
onClick={() => setDismissed(true)}
className="flex-shrink-0 text-amber-500 hover:text-amber-700 transition-colors"
aria-label="Chiudi avviso"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
);
}
export default NumLockBanner;

View File

@@ -0,0 +1,100 @@
/**
* Welcome Carousel Component - Focolari Voting System
*
* Carosello automatico di messaggi di benvenuto multilingua.
* Scorre automaticamente ogni 800ms durante la visualizzazione.
*/
import {useEffect, useState} from 'react';
interface WelcomeMessage {
lang: string;
text: string;
}
const WELCOME_MESSAGES: WelcomeMessage[] = [
{lang: 'it', text: 'Benvenuto!'},
{lang: 'en', text: 'Welcome!'},
{lang: 'fr', text: 'Bienvenue!'},
{lang: 'de', text: 'Willkommen!'},
{lang: 'es', text: '¡Bienvenido!'},
{lang: 'pt', text: 'Bem-vindo!'},
{lang: 'zh', text: '欢迎!'},
{lang: 'ja', text: 'ようこそ!'},
{lang: 'ar', text: '!مرحباً'},
{lang: 'ru', text: 'Добро пожаловать!'},
];
const CAROUSEL_INTERVAL_MS = 800;
interface WelcomeCarouselProps {
/** Se true, il carosello è in pausa */
paused?: boolean;
/** Nome dell'utente da visualizzare (opzionale) */
userName?: string;
}
export function WelcomeCarousel({paused = false, userName}: WelcomeCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
setIsTransitioning(true);
setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % WELCOME_MESSAGES.length);
setIsTransitioning(false);
}, 150); // Durata transizione fade
}, CAROUSEL_INTERVAL_MS);
return () => clearInterval(interval);
}, [paused]);
const currentMessage = WELCOME_MESSAGES[currentIndex];
return (
<div className="flex flex-col items-center justify-center text-center">
{/* Messaggio di benvenuto */}
<div
className={`transition-opacity duration-150 ${
isTransitioning ? 'opacity-0' : 'opacity-100'
}`}
>
<h1 className="text-6xl md:text-8xl font-bold text-white mb-4 drop-shadow-lg">
{currentMessage.text}
</h1>
<p className="text-xl text-white/70 uppercase tracking-wider">
{currentMessage.lang.toUpperCase()}
</p>
</div>
{/* Nome utente */}
{userName && (
<div className="mt-8 pt-8 border-t border-white/30">
<p className="text-3xl md:text-4xl text-white font-medium">
{userName}
</p>
</div>
)}
{/* Indicatori */}
<div className="flex gap-2 mt-8">
{WELCOME_MESSAGES.map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full transition-all duration-300 ${
index === currentIndex
? 'bg-white w-6'
: 'bg-white/40'
}`}
/>
))}
</div>
</div>
);
}
export default WelcomeCarousel;

View File

@@ -6,3 +6,5 @@ export { Modal } from './Modal';
export {RFIDStatus} from './RFIDStatus';
export {Button} from './Button';
export {Input} from './Input';
export {WelcomeCarousel} from './WelcomeCarousel';
export {NumLockBanner} from './NumLockBanner';

View File

@@ -1,22 +1,53 @@
/**
* Focolari Voting System - RFID Scanner Hook
* Focolari Voting System - RFID Scanner Hook (v2 - Multi-Pattern)
*
* Questo hook gestisce la lettura di badge RFID tramite lettori USB
* che emulano una tastiera. Il protocollo prevede:
* - Carattere di inizio: `;`
* - Carattere di fine: `?`
* - Esempio: `;00012345?`
* che emulano una tastiera. Supporta pattern multipli per diversi layout:
* - Layout US: `;` → `?`
* - Layout IT: `ò` → `_`
*
* NOTA: Il lettore RFID invia Enter (\n) dopo l'ultimo carattere.
* L'hook lo gestisce ignorando l'Enter immediatamente dopo il completamento.
*
* L'hook funziona indipendentemente dal focus dell'applicazione.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import type {RFIDScannerState, RFIDScanResult} from '../types';
// Costanti
const START_SENTINEL = ';';
const END_SENTINEL = '?';
const TIMEOUT_MS = 3000; // 3 secondi di timeout
// ============================================
// CONFIGURAZIONE PATTERN RFID
// ============================================
export interface RFIDPattern {
name: string;
start: string;
end: string;
}
export const VALID_PATTERNS: RFIDPattern[] = [
{name: 'US', start: ';', end: '?'},
{name: 'IT', start: 'ò', end: '_'},
];
const TIMEOUT_MS = 2500; // 2.5 secondi di timeout
const ENTER_GRACE_PERIOD_MS = 100; // Ignora Enter entro 100ms dal completamento
// ============================================
// LOGGING
// ============================================
const log = (message: string, ...args: unknown[]) => {
console.log(`[RFID] ${message}`, ...args);
};
const logWarn = (message: string, ...args: unknown[]) => {
console.warn(`[RFID] ${message}`, ...args);
};
// ============================================
// TIPI
// ============================================
interface UseRFIDScannerOptions {
/** Callback chiamato quando un badge viene letto con successo */
@@ -31,17 +62,33 @@ interface UseRFIDScannerOptions {
disabled?: boolean;
}
interface UseRFIDScannerReturn {
export interface UseRFIDScannerReturn {
/** Stato corrente dello scanner */
state: RFIDScannerState;
/** Buffer corrente (solo per debug) */
buffer: string;
/** Ultimo codice scansionato */
lastScan: RFIDScanResult | null;
/** Pattern attualmente in uso (solo durante scanning) */
activePattern: RFIDPattern | null;
/** Reset manuale dello scanner */
reset: () => void;
/** Ultimi eventi tastiera (per debug) */
keyLog: KeyLogEntry[];
}
export interface KeyLogEntry {
key: string;
code: string;
timestamp: number;
}
const MAX_KEY_LOG = 20;
// ============================================
// HOOK
// ============================================
export function useRFIDScanner({
onScan,
onTimeout,
@@ -52,11 +99,15 @@ export function useRFIDScanner({
const [state, setState] = useState<RFIDScannerState>('idle');
const [buffer, setBuffer] = useState<string>('');
const [lastScan, setLastScan] = useState<RFIDScanResult | null>(null);
const [activePattern, setActivePattern] = useState<RFIDPattern | null>(null);
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
// Refs per mantenere i valori aggiornati nei callback
const bufferRef = useRef<string>('');
const stateRef = useRef<RFIDScannerState>('idle');
const activePatternRef = useRef<RFIDPattern | null>(null);
const timeoutRef = useRef<number | null>(null);
const lastCompletionRef = useRef<number>(0);
// Sync refs con state
useEffect(() => {
@@ -67,6 +118,21 @@ export function useRFIDScanner({
stateRef.current = state;
}, [state]);
useEffect(() => {
activePatternRef.current = activePattern;
}, [activePattern]);
/**
* Aggiunge un evento al log tastiera
*/
const addKeyLog = useCallback((key: string, code: string) => {
setKeyLog(prev => {
const newEntry: KeyLogEntry = {key, code, timestamp: Date.now()};
const updated = [newEntry, ...prev].slice(0, MAX_KEY_LOG);
return updated;
});
}, []);
/**
* Pulisce il timeout attivo
*/
@@ -84,8 +150,10 @@ export function useRFIDScanner({
clearScanTimeout();
setState('idle');
setBuffer('');
setActivePattern(null);
bufferRef.current = '';
stateRef.current = 'idle';
activePatternRef.current = null;
}, [clearScanTimeout]);
/**
@@ -94,12 +162,19 @@ export function useRFIDScanner({
const startTimeout = useCallback(() => {
clearScanTimeout();
timeoutRef.current = window.setTimeout(() => {
console.warn('[RFID Scanner] Timeout - lettura incompleta scartata');
logWarn('Buffer timeout - clearing data');
onTimeout?.();
reset();
}, TIMEOUT_MS);
}, [clearScanTimeout, onTimeout, reset]);
/**
* Trova il pattern che corrisponde al carattere start
*/
const findPatternByStart = useCallback((char: string): RFIDPattern | undefined => {
return VALID_PATTERNS.find(p => p.start === char);
}, []);
/**
* Handler principale per gli eventi keydown
*/
@@ -110,11 +185,37 @@ export function useRFIDScanner({
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key;
const code = event.code;
// 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') {
// Log per debug
addKeyLog(key, code);
// Gestione Enter dopo completamento (grace period)
if (key === 'Enter' && Date.now() - lastCompletionRef.current < ENTER_GRACE_PERIOD_MS) {
log('Enter ignorato (post-completion grace period)');
if (preventDefaultOnScan) {
event.preventDefault();
}
return;
}
// Gestione ESC: annulla scansione in corso
if (key === 'Escape' && stateRef.current === 'scanning') {
log('Scansione annullata con ESC');
if (preventDefaultOnScan) {
event.preventDefault();
}
reset();
return;
}
// Ignora tasti speciali (frecce, funzione, ecc.) ma non i sentinel
const isStartSentinel = VALID_PATTERNS.some(p => p.start === key);
const isEndSentinel = activePatternRef.current?.end === key;
if (key.length > 1 && !isStartSentinel && !isEndSentinel) {
// Eccezione per Backspace/Enter in stato scanning: ignora ma non resetta
if ((key === 'Backspace' || key === 'Enter') && stateRef.current === 'scanning') {
if (preventDefaultOnScan) {
event.preventDefault();
}
@@ -122,18 +223,22 @@ export function useRFIDScanner({
return;
}
// STATO IDLE: attende il carattere di inizio
// STATO IDLE: attende un carattere start di qualsiasi pattern
if (stateRef.current === 'idle') {
if (key === START_SENTINEL) {
console.log('[RFID Scanner] Start sentinel rilevato - inizio scansione');
const pattern = findPatternByStart(key);
if (pattern) {
log(`Start sentinel detected: '${key}' (pattern ${pattern.name})`);
if (preventDefaultOnScan) {
event.preventDefault();
}
setState('scanning');
setActivePattern(pattern);
setBuffer('');
bufferRef.current = '';
activePatternRef.current = pattern;
startTimeout();
onScanStart?.();
}
@@ -147,14 +252,18 @@ export function useRFIDScanner({
event.preventDefault();
}
if (key === END_SENTINEL) {
const currentPattern = activePatternRef.current;
// Verifica se è l'end sentinel del pattern attivo
if (currentPattern && key === currentPattern.end) {
// Fine della scansione
clearScanTimeout();
lastCompletionRef.current = Date.now();
const scannedCode = bufferRef.current.trim();
if (scannedCode.length > 0) {
console.log('[RFID Scanner] Codice scansionato:', scannedCode);
log(`Scan complete (pattern ${currentPattern.name}): ${scannedCode}`);
const result: RFIDScanResult = {
code: scannedCode,
@@ -164,15 +273,18 @@ export function useRFIDScanner({
setLastScan(result);
onScan(scannedCode);
} else {
console.warn('[RFID Scanner] Codice vuoto scartato');
logWarn('Empty code discarded');
}
reset();
} else if (key === START_SENTINEL) {
// Nuovo start sentinel durante scansione: resetta e ricomincia
console.log('[RFID Scanner] Nuovo start sentinel - reset buffer');
} else if (findPatternByStart(key)) {
// Nuovo start sentinel durante scansione: resetta e ricomincia con nuovo pattern
const newPattern = findPatternByStart(key)!;
log(`New start sentinel during scan - switching to pattern ${newPattern.name}`);
setBuffer('');
bufferRef.current = '';
setActivePattern(newPattern);
activePatternRef.current = newPattern;
startTimeout();
} else {
// Accumula il carattere nel buffer
@@ -199,6 +311,8 @@ export function useRFIDScanner({
clearScanTimeout,
reset,
startTimeout,
findPatternByStart,
addKeyLog,
]);
// Cleanup al unmount
@@ -212,7 +326,9 @@ export function useRFIDScanner({
state,
buffer,
lastScan,
activePattern,
reset,
keyLog,
};
}

View File

@@ -1,10 +1,21 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {BrowserRouter, Route, Routes} from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import {DebugScreen} from './screens'
function DebugWrapper() {
return <DebugScreen onBack={() => window.location.href = '/'}/>
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<Routes>
<Route path="/" element={<App/>}/>
<Route path="/debug" element={<DebugWrapper/>}/>
</Routes>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -3,20 +3,26 @@
* Schermata principale del varco attivo
*/
import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components';
import type { RoomInfo, User, RFIDScannerState } from '../types';
import {useEffect, useState} from 'react';
import {Button, CountdownTimer, Logo, NumLockBanner, RFIDStatus, UserCard} from '../components';
import type {RFIDScannerState, RoomInfo, User} from '../types';
// Timeout per badge non trovato (30 secondi)
const NOT_FOUND_TIMEOUT_SECONDS = 30;
interface ActiveGateScreenProps {
roomInfo: RoomInfo;
rfidState: RFIDScannerState;
rfidBuffer: string;
currentUser: User | null;
notFoundBadge: string | null;
loading: boolean;
error?: string;
onCancelUser: () => void;
onLogout: () => void;
userTimeoutSeconds?: number;
onUserTimeout: () => void;
showValidatorBadgeNotice?: boolean;
}
export function ActiveGateScreen({
@@ -24,17 +30,41 @@ export function ActiveGateScreen({
rfidState,
rfidBuffer,
currentUser,
notFoundBadge,
loading,
error,
onCancelUser,
onLogout,
userTimeoutSeconds = 60,
onUserTimeout,
showValidatorBadgeNotice = false,
}: ActiveGateScreenProps) {
// Timer per countdown badge non trovato
const [notFoundCountdown, setNotFoundCountdown] = useState(NOT_FOUND_TIMEOUT_SECONDS);
// Reset countdown quando cambia notFoundBadge
useEffect(() => {
if (notFoundBadge) {
setNotFoundCountdown(NOT_FOUND_TIMEOUT_SECONDS);
const interval = setInterval(() => {
setNotFoundCountdown(prev => {
if (prev <= 1) {
clearInterval(interval);
onUserTimeout();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [notFoundBadge, onUserTimeout]);
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">
<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">
@@ -51,12 +81,23 @@ export function ActiveGateScreen({
</div>
</header>
{/* Notifica Badge Validatore Ignorato */}
{showValidatorBadgeNotice && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in">
<div className="bg-amber-100 border border-amber-400 text-amber-800 px-6 py-3 rounded-xl shadow-lg">
<p className="font-semibold">Badge validatore rilevato</p>
<p className="text-sm">Se il validatore è cambiato, esci e rilogga con il nuovo badge.</p>
</div>
</div>
)}
{/* Main Content */}
<main className="flex-1 flex items-center justify-center p-4 md:p-8">
{loading ? (
// Loading state
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6">
<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"
@@ -79,10 +120,42 @@ export function ActiveGateScreen({
</div>
<p className="text-xl text-gray-600">Caricamento dati...</p>
</div>
) : notFoundBadge ? (
// Badge non trovato
<div className="glass rounded-3xl p-12 md:p-16 shadow-xl text-center max-w-2xl w-full">
<div
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-error/10 mb-8">
<svg
className="h-16 w-16 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-2xl text-gray-700 mb-2">Utente con badge:</p>
<p className="text-3xl font-bold text-error font-mono mb-4">{notFoundBadge}</p>
<p className="text-2xl text-gray-700">non trovato nel sistema</p>
{/* Footer con countdown */}
<div className="mt-10 pt-6 border-t border-gray-200">
<p className="text-gray-500">
Ritorno all'attesa in <span
className="font-bold text-focolare-blue">{notFoundCountdown}</span> secondi
</p>
</div>
</div>
) : error ? (
// Error state
<div className="glass rounded-3xl p-12 shadow-xl 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">
<div className="glass rounded-3xl p-12 shadow-xl text-center max-w-md">
<div
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
<svg
className="h-10 w-10 text-error"
fill="none"
@@ -104,7 +177,7 @@ export function ActiveGateScreen({
</div>
) : currentUser ? (
// User found - Decision screen
<div className="w-full max-w-4xl animate-slide-up">
<div className="w-full max-w-5xl animate-slide-up">
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
{/* Timer bar */}
<div className="mb-6">
@@ -127,7 +200,9 @@ export function ActiveGateScreen({
✓ 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
Passa il <span
className="font-bold text-focolare-blue">badge VALIDATORE</span> per
confermare l'accesso
</p>
</div>
) : (
@@ -156,10 +231,12 @@ export function ActiveGateScreen({
</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">
<div
className="glass rounded-3xl p-12 md:p-16 shadow-xl animate-fade-in text-center max-w-3xl w-full">
<div
className="inline-flex items-center justify-center w-40 h-40 rounded-full bg-focolare-blue/10 mb-10">
<svg
className="w-16 h-16 text-focolare-blue"
className="w-20 h-20 text-focolare-blue"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -173,28 +250,33 @@ export function ActiveGateScreen({
</svg>
</div>
<h1 className="text-4xl font-bold text-focolare-blue mb-4">
<h1 className="text-5xl font-bold text-focolare-blue mb-6">
Varco Attivo
</h1>
<p className="text-2xl text-gray-600 mb-8">
<p className="text-3xl text-gray-600 mb-10">
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="py-10 px-8 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40">
<div className="flex items-center justify-center gap-4">
<svg
className="w-10 h-10 text-focolare-orange animate-pulse"
className="w-12 h-12 text-focolare-orange animate-pulse"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
<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">
<span className="text-3xl text-focolare-orange font-medium">
Passa il badge
</span>
</div>
</div>
{/* Banner NumLock per desktop */}
<NumLockBanner className="mt-8"/>
</div>
)}
</main>

View File

@@ -0,0 +1,208 @@
/**
* Debug Screen - Focolari Voting System
*
* Pagina diagnostica per debug del lettore RFID.
* Mostra in tempo reale gli eventi tastiera e lo stato dello scanner.
*/
import {useRFIDScanner, VALID_PATTERNS} from '../hooks/useRFIDScanner';
import {Button, Logo} from '../components';
interface DebugScreenProps {
onBack: () => void;
}
export function DebugScreen({onBack}: DebugScreenProps) {
const {
state,
buffer,
lastScan,
activePattern,
keyLog,
reset,
} = useRFIDScanner({
onScan: (code) => {
console.log('[DEBUG] Scan received:', code);
},
onTimeout: () => {
console.log('[DEBUG] Timeout occurred');
},
});
const formatTimestamp = (ts: number) => {
const date = new Date(ts);
return date.toLocaleTimeString('it-IT', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
});
};
return (
<div className="min-h-screen bg-slate-900 text-white p-4 md:p-6">
{/* Header */}
<header className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Logo size="sm"/>
<h1 className="text-2xl font-bold text-white">Debug RFID</h1>
</div>
<Button variant="secondary" onClick={onBack}>
Torna all'app
</Button>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Scanner Status */}
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4 text-focolare-orange">
📡 Stato Scanner
</h2>
<div className="space-y-4">
{/* State */}
<div className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
<span className="text-slate-300">Stato</span>
<span className={`px-3 py-1 rounded-full font-mono font-bold ${
state === 'scanning'
? 'bg-focolare-orange text-white animate-pulse'
: 'bg-slate-600 text-slate-300'
}`}>
{state.toUpperCase()}
</span>
</div>
{/* Active Pattern */}
<div className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
<span className="text-slate-300">Pattern Attivo</span>
<span className="font-mono text-lg">
{activePattern
? `${activePattern.name} (${activePattern.start} → ${activePattern.end})`
: ''
}
</span>
</div>
{/* Buffer */}
<div className="p-3 bg-slate-700 rounded-lg">
<span className="text-slate-300 block mb-2">Buffer Corrente</span>
<div className="font-mono text-2xl bg-slate-900 p-3 rounded min-h-[50px] break-all">
{buffer || <span className="text-slate-500">(vuoto)</span>}
</div>
</div>
{/* Last Scan */}
<div className="p-3 bg-slate-700 rounded-lg">
<span className="text-slate-300 block mb-2">Ultimo Codice Rilevato</span>
{lastScan ? (
<div className="bg-green-900/50 border border-green-500 rounded p-3">
<p className="font-mono text-2xl text-green-400">{lastScan.code}</p>
<p className="text-sm text-green-300 mt-1">
{formatTimestamp(lastScan.timestamp)}
</p>
</div>
) : (
<div className="bg-slate-900 p-3 rounded text-slate-500">
Nessuna scansione
</div>
)}
</div>
{/* Reset Button */}
<Button variant="secondary" onClick={reset} className="w-full">
Reset Scanner
</Button>
</div>
</div>
{/* Key Log */}
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4 text-focolare-blue">
⌨️ Log Tastiera (ultimi 20)
</h2>
<div className="bg-slate-900 rounded-lg overflow-hidden">
<div className="grid grid-cols-3 gap-2 p-3 bg-slate-700 text-sm font-semibold">
<span>Ora</span>
<span>Key</span>
<span>Code</span>
</div>
<div className="max-h-[400px] overflow-y-auto">
{keyLog.length === 0 ? (
<div className="p-4 text-slate-500 text-center">
Premi un tasto per vedere i log...
</div>
) : (
keyLog.map((entry, index) => {
const isStartSentinel = VALID_PATTERNS.some(p => p.start === entry.key);
const isEndSentinel = VALID_PATTERNS.some(p => p.end === entry.key);
let rowClass = 'bg-slate-900';
if (isStartSentinel) rowClass = 'bg-green-900/30';
if (isEndSentinel) rowClass = 'bg-blue-900/30';
return (
<div
key={`${entry.timestamp}-${index}`}
className={`grid grid-cols-3 gap-2 p-2 border-b border-slate-700 text-sm ${rowClass}`}
>
<span className="text-slate-400 font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</span>
<span className={`font-mono ${
isStartSentinel ? 'text-green-400 font-bold' :
isEndSentinel ? 'text-blue-400 font-bold' :
'text-white'
}`}>
{entry.key === ' ' ? '(space)' :
entry.key === 'Enter' ? ' Enter' :
entry.key}
</span>
<span className="text-slate-400 font-mono text-xs">
{entry.code}
</span>
</div>
);
})
)}
</div>
</div>
</div>
{/* Pattern Reference */}
<div className="bg-slate-800 rounded-xl p-6 lg:col-span-2">
<h2 className="text-xl font-semibold mb-4 text-focolare-yellow">
📋 Pattern Supportati
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{VALID_PATTERNS.map((pattern) => (
<div
key={pattern.name}
className="bg-slate-700 rounded-lg p-4 text-center"
>
<h3 className="text-lg font-bold mb-2">{pattern.name}</h3>
<div className="flex items-center justify-center gap-4 text-3xl font-mono">
<span className="text-green-400">{pattern.start}</span>
<span className="text-slate-500">→</span>
<span className="text-blue-400">{pattern.end}</span>
</div>
<p className="text-sm text-slate-400 mt-2">
Esempio: {pattern.start}123456{pattern.end}
</p>
</div>
))}
</div>
<p className="mt-4 text-sm text-slate-400">
💡 <strong>Nota:</strong> Il lettore RFID invia anche Enter (↵) dopo l'ultimo carattere.
L'hook lo gestisce automaticamente.
</p>
</div>
</div>
</div>
);
}
export default DebugScreen;

View File

@@ -3,7 +3,7 @@
* Modal per errori
*/
import { Modal, Button } from '../components';
import {Button, Modal} from '../components';
interface ErrorModalProps {
isOpen: boolean;
@@ -28,7 +28,8 @@ export function ErrorModal({
<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">
<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"

View File

@@ -2,7 +2,9 @@
* Loading Screen - Focolari Voting System
*/
import {useCallback, useEffect, useState} from 'react';
import {Logo} from '../components';
import {checkServerHealth} from '../services/api';
interface LoadingScreenProps {
message?: string;
@@ -15,8 +17,51 @@ export function LoadingScreen({
error,
onRetry
}: LoadingScreenProps) {
const [isRetrying, setIsRetrying] = useState(false);
const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking');
// Ping automatico quando c'è un errore
useEffect(() => {
if (!error) return;
const checkServer = async () => {
setServerStatus('checking');
const isOnline = await checkServerHealth();
setServerStatus(isOnline ? 'online' : 'offline');
};
checkServer();
// Ripeti il ping ogni 3 secondi
const interval = setInterval(checkServer, 3000);
return () => clearInterval(interval);
}, [error]);
const handleRetry = useCallback(async () => {
if (!onRetry) return;
setIsRetrying(true);
const isOnline = await checkServerHealth();
if (isOnline) {
onRetry();
} else {
setServerStatus('offline');
}
setIsRetrying(false);
}, [onRetry]);
// Se il server torna online, riprova automaticamente
useEffect(() => {
if (serverStatus === 'online' && error && onRetry) {
console.log('[FLOW] Server tornato online, ricarico...');
onRetry();
}
}, [serverStatus, error, onRetry]);
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-br from-focolare-blue/5 to-focolare-blue/20">
<div
className="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}/>
@@ -27,7 +72,8 @@ export function LoadingScreen({
{!error ? (
<>
<div className="mt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10">
<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"
@@ -70,14 +116,32 @@ export function LoadingScreen({
</svg>
</div>
</div>
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
{/* Server status indicator */}
<div className="mt-4 flex items-center justify-center gap-2">
<span className={`w-3 h-3 rounded-full ${
serverStatus === 'checking' ? 'bg-yellow-500 animate-pulse' :
serverStatus === 'online' ? 'bg-green-500' :
'bg-red-500'
}`}/>
<span className="text-sm text-gray-600">
{serverStatus === 'checking' && 'Verifica connessione...'}
{serverStatus === 'online' && 'Server raggiungibile - riconnessione...'}
{serverStatus === 'offline' && 'Server non raggiungibile'}
</span>
</div>
{onRetry && (
<button
onClick={onRetry}
onClick={handleRetry}
disabled={isRetrying}
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
font-semibold hover:bg-focolare-blue-dark transition-colors"
font-semibold hover:bg-focolare-blue/90 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
Riprova
{isRetrying ? 'Verifica in corso...' : 'Riprova'}
</button>
)}
</>

View File

@@ -1,21 +1,19 @@
/**
* Success Modal - Focolari Voting System
* Modal fullscreen per conferma ingresso
* Modal fullscreen per conferma ingresso con carosello multilingua
*/
import { Modal } from '../components';
import {Modal, WelcomeCarousel} from '../components';
interface SuccessModalProps {
isOpen: boolean;
onClose: () => void;
welcomeMessage: string;
userName?: string;
}
export function SuccessModal({
isOpen,
onClose,
welcomeMessage,
userName
}: SuccessModalProps) {
return (
@@ -26,10 +24,11 @@ export function SuccessModal({
autoCloseMs={5000}
fullscreen
>
<div className="text-center text-white p-8">
<div className="flex flex-col items-center justify-center min-h-screen p-8">
{/* Success Icon */}
<div className="mb-8">
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow">
<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"
@@ -46,26 +45,17 @@ export function SuccessModal({
</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>
{/* Carosello Messaggi Benvenuto */}
<WelcomeCarousel userName={userName}/>
{/* Sub text */}
<p className="text-2xl md:text-3xl opacity-80 animate-fade-in">
<p className="text-2xl md:text-3xl text-white/80 mt-8 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="mt-12 w-full max-w-md">
<div className="w-full h-2 bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-white rounded-full"
style={{

View File

@@ -2,9 +2,9 @@
* Validator Login Screen - Focolari Voting System
*/
import { useState, useRef, useEffect } from 'react';
import { Logo, Button, Input, RFIDStatus } from '../components';
import type { RoomInfo, RFIDScannerState } from '../types';
import {useEffect, useRef, useState} from 'react';
import {Button, Input, Logo, NumLockBanner, RFIDStatus} from '../components';
import type {RFIDScannerState, RoomInfo} from '../types';
interface ValidatorLoginScreenProps {
roomInfo: RoomInfo;
@@ -62,7 +62,8 @@ export function ValidatorLoginScreen({
// 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">
<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"
@@ -83,30 +84,44 @@ export function ValidatorLoginScreen({
</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
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="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"/>
<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>
{/* Banner NumLock per desktop */}
<NumLockBanner className="mt-6"/>
{/* Messaggio errore */}
{error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
</div>
</>
) : (
// Form password
<>
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
<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"

View File

@@ -4,3 +4,4 @@ export { ValidatorLoginScreen } from './ValidatorLoginScreen';
export {ActiveGateScreen} from './ActiveGateScreen';
export {SuccessModal} from './SuccessModal';
export {ErrorModal} from './ErrorModal';
export {DebugScreen} from './DebugScreen';

View File

@@ -2,28 +2,39 @@
* Focolari Voting System - API Service
*/
import type {
RoomInfo,
User,
LoginRequest,
LoginResponse,
EntryRequest,
EntryResponse
} from '../types';
import type {EntryRequest, EntryResponse, LoginRequest, LoginResponse, RoomInfo, User} from '../types';
const API_BASE_URL = 'http://localhost:8000';
// Path relativi: funziona sia in dev (proxy Vite) che in produzione (stesso server)
const API_BASE_URL = '';
// ============================================
// LOGGING
// ============================================
const log = (message: string, ...args: unknown[]) => {
console.log(`[API] ${message}`, ...args);
};
const logError = (message: string, ...args: unknown[]) => {
console.error(`[API] ${message}`, ...args);
};
/**
* Custom error class for API errors
*/
export class ApiError extends Error {
public statusCode: number;
public detail?: string;
constructor(
message: string,
public statusCode: number,
public detail?: string
statusCode: number,
detail?: string
) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
this.detail = detail;
}
}
@@ -34,6 +45,8 @@ async function apiFetch<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
log(`Fetching ${options?.method || 'GET'} ${endpoint}`);
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
@@ -45,6 +58,7 @@ async function apiFetch<T>(
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
logError(`Error ${response.status}: ${errorData.detail || 'Unknown error'}`);
throw new ApiError(
errorData.detail || `HTTP Error ${response.status}`,
response.status,
@@ -52,11 +66,14 @@ async function apiFetch<T>(
);
}
return await response.json();
const data = await response.json();
log(`Response OK from ${endpoint}`);
return data;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
logError('Connection error:', error);
throw new ApiError(
'Errore di connessione al server',
0,
@@ -85,6 +102,7 @@ export async function loginValidator(
badge: string,
password: string
): Promise<LoginResponse> {
log(`Login attempt for badge: ${badge}`);
const payload: LoginRequest = {badge, password};
return apiFetch<LoginResponse>('/login-validate', {
method: 'POST',
@@ -97,6 +115,7 @@ export async function loginValidator(
* Ottiene i dati anagrafici di un utente tramite badge
*/
export async function getUserByBadge(badgeCode: string): Promise<User> {
log(`Fetching anagrafica for badge: ${badgeCode}`);
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
}
@@ -108,6 +127,7 @@ export async function requestEntry(
userBadge: string,
validatorPassword: string
): Promise<EntryResponse> {
log(`Entry request for badge: ${userBadge}`);
const payload: EntryRequest = {
user_badge: userBadge,
validator_password: validatorPassword,

View File

@@ -1,101 +1,103 @@
/**
}
variant?: 'success' | 'error' | 'info';
children: React.ReactNode;
onClose?: () => void;
isOpen: boolean;
export interface ModalProps {
}
paused?: boolean;
onExpire: () => void;
seconds: number;
export interface TimerProps {
}
showWarning?: boolean;
user: User;
export interface UserCardProps {
// ============================================
// Component Props Types
// ============================================
}
timestamp: number;
code: string;
export interface RFIDScanResult {
export type RFIDScannerState = 'idle' | 'scanning';
// ============================================
// RFID Scanner Types
// ============================================
}
expiresAt: number;
loginTime: number;
token: string;
badge: string;
export interface ValidatorSession {
| 'entry-error';
| 'entry-success'
| 'showing-user'
| 'gate-active'
| 'validator-password'
| 'waiting-validator'
| 'loading'
export type AppState =
// ============================================
// Application State Types
// ============================================
}
validator_password: string;
user_badge: string;
export interface EntryRequest {
}
password: string;
badge: string;
export interface LoginRequest {
// ============================================
// Request Types
// ============================================
}
welcome_message?: string;
message: string;
success: boolean;
export interface EntryResponse {
}
token?: string;
message: string;
success: boolean;
export interface LoginResponse {
}
warning?: string;
ammesso: boolean;
ruolo: 'Tecnico' | 'Votante' | 'Ospite';
url_foto: string;
cognome: string;
nome: string;
badge_code: string;
export interface User {
}
meeting_id: string;
room_name: string;
export interface RoomInfo {
* Focolari Voting System - TypeScript Types
*/
// ============================================
// API Response Types
// ============================================
*/
* Focolari Voting System - TypeScript Types
export interface RoomInfo {
room_name: string;
meeting_id: string;
server_start_time: number;
}
export interface User {
badge_code: string;
nome: string;
cognome: string;
url_foto: string;
ruolo: 'Tecnico' | 'Votante' | 'Ospite';
ammesso: boolean;
warning?: string;
}
export interface LoginResponse {
success: boolean;
message: string;
token?: string;
}
export interface EntryResponse {
success: boolean;
message: string;
}
// ============================================
// Request Types
// ============================================
export interface LoginRequest {
badge: string;
password: string;
}
export interface EntryRequest {
user_badge: string;
validator_password: string;
}
// ============================================
// Application State Types
// ============================================
export type AppState =
| 'loading'
| 'waiting-validator'
| 'validator-password'
| 'gate-active'
| 'showing-user'
| 'entry-success'
| 'entry-error';
export interface ValidatorSession {
badge: string;
password: string;
token: string;
loginTime: number;
expiresAt: number;
serverStartTime: number; // Per invalidare se il server riparte
}
// ============================================
// RFID Scanner Types
// ============================================
export type RFIDScannerState = 'idle' | 'scanning';
export interface RFIDScanResult {
code: string;
timestamp: number;
}
// ============================================
// Component Props Types
// ============================================
export interface UserCardProps {
user: User;
showWarning?: boolean;
}
export interface TimerProps {
seconds: number;
onExpire: () => void;
paused?: boolean;
}
export interface ModalProps {
isOpen: boolean;
onClose?: () => void;
children: React.ReactNode;
variant?: 'success' | 'error' | 'info';
}

View File

@@ -3,11 +3,16 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": ["vite/client"],
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -15,7 +20,6 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -24,5 +28,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": [
"src"
]
}

View File

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

View File

@@ -2,18 +2,20 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"lib": [
"ES2023"
],
"module": "ESNext",
"types": ["node"],
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -22,5 +24,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": [
"vite.config.ts"
]
}

View File

@@ -5,4 +5,36 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
// Path relativi per funzionare su qualsiasi IP/porta
base: './',
// Build nella cartella dist del frontend
build: {
outDir: 'dist',
emptyOutDir: true,
},
// Proxy API in sviluppo verso il backend (porta 8000)
// In produzione il backend serve direttamente il frontend
server: {
proxy: {
'/info-room': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/login-validate': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/anagrafica': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/entry-request': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})