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 ## 📖 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 ## 🏗️ Struttura Progetto
``` ```
VotoFocolari/ VotoFocolari/
├── dev.sh # Script sviluppo (install, dev, server, build, ...)
├── ai-prompts/ # Documentazione sviluppo e prompt ├── ai-prompts/ # Documentazione sviluppo e prompt
├── backend-mock/ # API mock in Python FastAPI ├── backend-mock/ # API mock in Python FastAPI
│ └── static/ # Frontend buildato (generato)
└── frontend/ # App React + TypeScript + Tailwind └── frontend/ # App React + TypeScript + Tailwind
``` ```
## 🚀 Quick Start ## 🚀 Quick Start
### Backend ### Setup Iniziale
```bash ```bash
cd backend-mock ./dev.sh install
pipenv install
pipenv run start
# Server: http://localhost:8000
# Docs API: http://localhost:8000/docs
``` ```
### Frontend ### Sviluppo (hot reload)
```bash ```bash
cd frontend ./dev.sh dev
npm install # Backend API: http://localhost:8000
npm run dev # Frontend Dev: http://localhost:5173
# App: 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 ## 📚 Documentazione
Per dettagli tecnici, consulta la cartella `ai-prompts/`: Per dettagli tecnici, consulta la cartella `ai-prompts/`:
- `00-welcome-agent.md` - Panoramica progetto - `00-welcome-agent.md` - Panoramica progetto
- `01-backend-plan.md` - Piano sviluppo backend - `01-backend-plan.md` - Piano sviluppo backend
- `02-frontend-plan.md` - Piano sviluppo frontend - `02-frontend-plan.md` - Piano sviluppo frontend
@@ -46,6 +65,10 @@ Per dettagli tecnici, consulta la cartella `ai-prompts/`:
- **Badge Validatore:** `999999` - **Badge Validatore:** `999999`
- **Password:** `focolari` - **Password:** `focolari`
## 🔍 Debug
Accedi a `/debug` per diagnostica RFID in tempo reale.
## 📄 Licenza ## 📄 Licenza
Progetto privato - Movimento dei Focolari Progetto privato - Movimento dei Focolari

View File

@@ -1,206 +1,150 @@
# 🎯 Focolari Voting System - Guida Agente # 🗳️ Focolari Voting System - Welcome Agent
## Panoramica Progetto ## Panoramica Progetto
**Nome:** Sistema Controllo Accessi "Focolari Voting System" Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focolari**.
**Committente:** Movimento dei Focolari Applicazione web ottimizzata per **tablet in orizzontale** che gestisce i varchi d'ingresso alle sale votazione tramite
**Scopo:** Gestione dei varchi di accesso per le assemblee di voto del Movimento. **lettori RFID USB** (emulano tastiera).
### Contesto d'Uso
Il sistema funziona su **tablet Android** (via browser Chrome) posizionati ai varchi d'ingresso delle sale votazione. Ogni tablet è collegato a un **lettore RFID USB** che emula tastiera.
--- ---
## 🏗️ Architettura ## 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/ VotoFocolari/
├── ai-prompts/ # Documentazione e piani di sviluppo ├── dev.sh # Script di sviluppo (install, dev, server, ...)
├── backend-mock/ # Python FastAPI (server mock) ├── README.md
├── main.py # Entry point con argparse ├── ai-prompts/ # Documentazione sviluppo
│ ├── Pipfile # Dipendenze pipenv │ ├── 00-welcome-agent.md # Questo file
│ ├── api/ # Routes FastAPI │ ├── 01-backend-plan.md # Piano backend
── schemas/ # Modelli Pydantic ── 02-frontend-plan.md # Piano frontend
│ └── data/ # Dataset JSON (default, test) ├── backend-mock/
└── frontend/ # React + TypeScript + Vite + Tailwind │ ├── main.py # Entry point con argparse
│ ├── 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/ └── src/
├── App.tsx # State machine principale ├── App.tsx # State machine principale
├── hooks/ # Custom hooks (RFID scanner) ├── hooks/ # useRFIDScanner
├── components/ # UI components ├── components/ # UI components
├── screens/ # Schermate complete ├── screens/ # Schermate
├── services/ # API layer ├── services/ # API client
── types/ # TypeScript definitions ── types/ # TypeScript types
└── tests/ # Test automatici use case
``` ```
--- ---
## 🔧 Stack Tecnologico ## Funzionalità Principali
### Backend Mock ### Flusso Utente
- **Python 3.10+**
- **FastAPI** - Framework web asincrono
- **Uvicorn** - ASGI server
- **Pydantic** - Validazione dati
- **pipenv** - Gestione ambiente virtuale
- **argparse** - Parametri CLI (porta, dataset)
### Frontend 1. **Login Validatore**: Passa badge + inserisci password → Sessione 30 min
- **React 19** - UI Library 2. **Attesa Partecipante**: Schermata grande "Passa il badge"
- **TypeScript 5.9** - Type safety 3. **Visualizzazione Utente**: Card con foto, nome, ruolo, stato ammissione
- **Vite 7** - Build tool 4. **Conferma Ingresso**: Validatore ripassa il badge → Carosello benvenuto multilingua
- **Tailwind CSS 4** - Styling utility-first
- **React Router** - Routing (/, /debug) ### Gestione RFID
- **Vitest** - Testing framework
- **Design:** Ottimizzato per touch tablet - **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 ```bash
cd backend-mock # Setup iniziale
pipenv install ./dev.sh install
pipenv run python main.py # Default
pipenv run python main.py -p 9000 # Porta custom
pipenv run python main.py -d data/test.json # Dataset test
```
### Frontend # Sviluppo (hot reload)
```bash ./dev.sh dev
cd frontend # Frontend: http://localhost:5173
npm install # Backend: http://localhost:8000
npm run dev # Sviluppo
npm run test # Test suite # Produzione locale
npm run test:ui # Test con UI ./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 | **Password validatore:** `focolari`
|------|-------------|
| UC01 | Login validatore |
| UC02 | Accesso partecipante ammesso |
| UC03 | Accesso negato |
| UC04 | Timeout sessione |
| UC05 | Cambio rapido badge |
| UC06 | Pattern RFID multipli |
--- ---
## 🔍 Debug ## Dove Trovare Cosa
### Console Browser | Cosa cerchi | Dove guardare |
Tutti i log sono prefissati: |----------------------------|----------------------------------------|
- `[RFID]` - Eventi lettore badge | Logica flusso applicazione | `frontend/src/App.tsx` |
- `[FLOW]` - Transizioni stato | Hook lettura RFID | `frontend/src/hooks/useRFIDScanner.ts` |
- `[API]` - Chiamate HTTP | Chiamate API | `frontend/src/services/api.ts` |
| Endpoint backend | `backend-mock/api/routes.py` |
### Pagina Debug (`/debug`) | Dati mock utenti | `backend-mock/data/users_default.json` |
Accesso: logo cliccabile 5 volte. | Configurazione Vite | `frontend/vite.config.ts` |
Mostra in tempo reale:
- Ultimi 20 tasti premuti
- Stato scanner (idle/scanning)
- Buffer corrente
- Pattern attivo
--- ---
## ⚠️ Note Importanti ## Note Importanti
1. **Badge Validatore:** `999999` con password `focolari` 1. **Layout Tastiera**: Il lettore RFID potrebbe inviare caratteri diversi in base al layout OS. La pagina `/debug`
2. **Sessione:** 30 minuti di timeout, salvata in localStorage aiuta a diagnosticare.
3. **Timeout utente:** 60 secondi sulla schermata decisione
4. **Multi-layout:** Il sistema supporta RFID su tastiera US e IT 2. **Server Restart**: Quando il server riparte, tutte le sessioni frontend vengono invalidate (controllato via
5. **Backend asettico:** Nessun messaggio multilingua dal server `server_start_time`).
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
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 - [ ] Verificare se il badge validatore debba essere validato anche lato server
- `02-frontend-plan.md` - Piano sviluppo frontend - [ ] Test automatici E2E per regression detection

View File

@@ -13,7 +13,6 @@ Struttura modulare con separazione tra API, modelli e dati.
backend-mock/ backend-mock/
├── main.py # Entry point con argparse ├── main.py # Entry point con argparse
├── Pipfile # Dipendenze pipenv ├── Pipfile # Dipendenze pipenv
├── requirements.txt # Backup dipendenze
├── .gitignore ├── .gitignore
├── api/ ├── api/
│ ├── __init__.py │ ├── __init__.py
@@ -22,7 +21,7 @@ backend-mock/
│ ├── __init__.py │ ├── __init__.py
│ └── models.py # Modelli Pydantic │ └── models.py # Modelli Pydantic
└── data/ └── data/
├── users_default.json # Dataset utenti default ├── users_default.json # Dataset utenti default (badge reali)
└── users_test.json # Dataset per test └── users_test.json # Dataset per test
``` ```
@@ -35,36 +34,40 @@ backend-mock/
- [x] Creare cartella `backend-mock/` - [x] Creare cartella `backend-mock/`
- [x] Creare `Pipfile` per pipenv - [x] Creare `Pipfile` per pipenv
- [x] Configurare `.gitignore` per Python - [x] Configurare `.gitignore` per Python
- [ ] Creare struttura cartelle (`api/`, `schemas/`, `data/`) - [x] Creare struttura cartelle (`api/`, `schemas/`, `data/`)
### 2. Modelli Pydantic (`schemas/models.py`) ### 2. Modelli Pydantic (`schemas/models.py`)
- [x] `LoginRequest` - badge + password - [x] `LoginRequest` - badge + password
- [x] `EntryRequest` - user_badge + validator_password - [x] `EntryRequest` - user_badge + validator_password
- [x] `UserResponse` - dati utente + warning opzionale - [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] `LoginResponse` - success + message + token
- [x] `EntryResponse` - success + message (SENZA welcome_message) - [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`) ### 3. Dati Mock (`data/*.json`)
- [x] Costanti validatore (badge `999999`, password `focolari`) - [x] Password validatore (solo password, badge gestito dal frontend)
- [x] Lista utenti mock (7 utenti con dati realistici) - [x] Lista utenti mock con **badge reali**:
- [x] Mix di ruoli: Votante, Tecnico, Ospite - `0008988288` - Marco Bianchi (Votante, ammesso)
- [x] Alcuni con `ammesso: false` per test - `0007399575` - Laura Rossi (Votante, ammessa)
- [x] URL foto placeholder (randomuser.me) - `0000514162` - Giuseppe Verdi (Tecnico, NON ammesso)
- [ ] **DA FARE:** Estrarre dati in file JSON separato - `0006478281` - **NON nel DB** (per test "non trovato")
- [ ] **DA FARE:** Creare dataset alternativo per test - [x] Estrarre dati in file JSON separato
- [ ] **DA FARE:** Caricare dati dinamicamente all'avvio - [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. **Nota:** I messaggi di benvenuto multilingua sono gestiti dal frontend con carosello.
Il frontend gestirà autonomamente la visualizzazione internazionale 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`) ### 4. Routes API (`api/routes.py`)
- [x] `GET /info-room` - info sala - [x] `GET /info-room` - info sala + **server_start_time** per invalidare sessioni
- [x] `POST /login-validate` - autenticazione validatore - [x] `POST /login-validate` - verifica solo password validatore
- [x] `GET /anagrafica/{badge_code}` - ricerca utente - [x] `GET /anagrafica/{badge_code}` - ricerca utente
- [x] Pulizia caratteri sentinel dal badge - [x] Pulizia caratteri sentinel dal badge
- [x] Confronto con e senza zeri iniziali - [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 password validatore
- [x] Verifica utente ammesso - [x] Verifica utente ammesso
- [x] Risposta asettica (solo success + message) - [x] Risposta asettica (solo success + message)
- [ ] **DA FARE:** Spostare routes in file dedicato - [x] Spostare routes in file dedicato
### 5. Entry Point (`main.py`) ### 5. Entry Point (`main.py`)
- [x] Blocco `if __name__ == "__main__"` - [x] Blocco `if __name__ == "__main__"`
- [x] Configurazione uvicorn (host, port) - [x] Configurazione uvicorn (host, port)
- [x] Messaggi console all'avvio - [x] Messaggi console all'avvio
- [ ] **DA FARE:** Implementare argparse con opzioni: - [x] Implementare argparse con opzioni:
- `--port` / `-p` : porta server (default: 8000) - `--port` / `-p` : porta server (default: 8000)
- `--data` / `-d` : path file JSON dati (default: `data/users_default.json`) - `--data` / `-d` : path file JSON dati (default: `data/users_default.json`)
- `--host` : host binding (default: `0.0.0.0`) - `--host` : host binding (default: `0.0.0.0`)
- [ ] **DA FARE:** Caricamento dinamico dati da JSON - [x] Caricamento dinamico dati da JSON
- [ ] **DA FARE:** Import routes da modulo `api` - [x] Import routes da modulo `api`
### 6. Struttura Base FastAPI ### 6. Invalidazione Sessioni
- [x] Import FastAPI e dipendenze - [x] `SERVER_START_TIME` generato all'avvio del server
- [x] Configurazione app con titolo e descrizione - [x] Restituito in `/info-room` per permettere al frontend di invalidare sessioni vecchie
- [x] Middleware CORS (allow all origins) - [x] Se il server riparte, tutte le sessioni frontend vengono invalidate
- [x] Endpoint root `/` per health check
- [ ] **DA FARE:** Refactor in struttura modulare
--- ---
@@ -101,19 +102,16 @@ Il frontend gestirà autonomamente la visualizzazione internazionale con carosel
```json ```json
{ {
"validator": { "validator_password": "focolari",
"badge": "999999",
"password": "focolari"
},
"room": { "room": {
"room_name": "Sala Assemblea", "room_name": "Sala Assemblea",
"meeting_id": "VOT-2024" "meeting_id": "VOT-2024"
}, },
"users": [ "users": [
{ {
"badge_code": "000001", "badge_code": "0008988288",
"nome": "Maria", "nome": "Marco",
"cognome": "Rossi", "cognome": "Bianchi",
"url_foto": "https://...", "url_foto": "https://...",
"ruolo": "Votante", "ruolo": "Votante",
"ammesso": true "ammesso": true
@@ -126,25 +124,22 @@ Il frontend gestirà autonomamente la visualizzazione internazionale con carosel
## Comandi Esecuzione ## 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 ```bash
cd backend-mock cd backend-mock
pipenv install pipenv install
pipenv run python main.py 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 ## Test Rapidi
@@ -153,29 +148,28 @@ pipenv run python main.py -p 9000 -d data/users_test.json
# Health check # Health check
curl http://localhost:8000/ curl http://localhost:8000/
# Info sala # Info sala (include server_start_time)
curl http://localhost:8000/info-room curl http://localhost:8000/info-room
# Ricerca utente # Ricerca utente reale
curl http://localhost:8000/anagrafica/000001 curl http://localhost:8000/anagrafica/0008988288
# Badge non trovato
curl http://localhost:8000/anagrafica/0006478281
# Login validatore # Login validatore
curl -X POST http://localhost:8000/login-validate \ curl -X POST http://localhost:8000/login-validate \
-H "Content-Type: application/json" \ -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 \ curl -X POST http://localhost:8000/entry-request \
-H "Content-Type: application/json" \ -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 Tutti i task sono stati implementati e testati.
- Il confronto badge avviene sia con che senza zeri iniziali
- Le risposte seguono lo standard HTTP (200 OK, 401 Unauthorized, 404 Not Found, 403 Forbidden)
- La documentazione OpenAPI è auto-generata su `/docs`
- **Risposta `/entry-request`:** JSON asettico `{ success: true, message: "..." }` senza messaggi multilingua

View File

@@ -3,7 +3,7 @@
## Obiettivo ## Obiettivo
Applicazione React per tablet che gestisce il flusso di accesso ai varchi votazione, con lettura badge RFID. 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] Inizializzare Vite + React + TypeScript
- [x] Installare Tailwind CSS 4 - [x] Installare Tailwind CSS 4
- [x] Configurare `tsconfig.json` - [x] Configurare `tsconfig.json`
- [x] Configurare `vite.config.ts` - [x] Configurare `vite.config.ts` con proxy API
- [x] Configurare `.gitignore` - [x] Configurare `.gitignore`
- [x] Path relativi per build (base: './')
- [x] Output build in `frontend/dist/`
- [x] Favicon con logo Focolari
### 2. Design System ### 2. Design System
@@ -29,15 +32,14 @@ Include sistema di test automatici per validazione e regression detection.
### 3. Tipi TypeScript (`types/index.ts`) ### 3. Tipi TypeScript (`types/index.ts`)
- [x] `RoomInfo` - info sala - [x] `RoomInfo` - info sala + **server_start_time**
- [x] `User` - dati utente - [x] `User` - dati utente
- [x] `LoginRequest/Response` - [x] `LoginRequest/Response`
- [x] `EntryRequest/Response` (SENZA welcome_message) - [x] `EntryRequest/Response` (SENZA welcome_message)
- [x] `AppState` - stati applicazione - [x] `AppState` - stati applicazione
- [x] `ValidatorSession` - sessione validatore - [x] `ValidatorSession` - sessione validatore + **serverStartTime**
- [x] `RFIDScannerState` - stato scanner - [x] `RFIDScannerState` - stato scanner
- [x] `RFIDScanResult` - risultato scan - [x] `RFIDScanResult` - risultato scan
- [ ] **DA FARE:** Aggiornare `EntryResponse` rimuovendo `welcome_message`
### 4. API Service (`services/api.ts`) ### 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] `loginValidator()` - POST /login-validate
- [x] `getUserByBadge()` - GET /anagrafica/{badge} - [x] `getUserByBadge()` - GET /anagrafica/{badge}
- [x] `requestEntry()` - POST /entry-request - [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`) ### 5. Hook RFID Scanner (`hooks/useRFIDScanner.ts`)
#### Implementazione Base (v1) - [x] Supporto pattern multipli (US: `;?`, IT: `ò_`)
- [x] Rilevamento automatico pattern in uso
- [x] Listener `keydown` globale - [x] Gestione Enter post-completamento
- [x] Stati: idle → scanning → idle - [x] Timeout 2.5s per scansioni accidentali
- [x] Singolo pattern (`;` / `?`) - [x] ESC annulla scansione in corso
- [x] Timeout sicurezza (3s) - [x] Logging avanzato con prefisso `[RFID]`
- [x] `preventDefault` durante scan - [x] Export `keyLog` per debug
- [x] Callback `onScan`, `onTimeout`, `onScanStart`
#### ⚠️ AGGIORNAMENTO RICHIESTO (v2) - Multi-Pattern
- [ ] Supporto pattern multipli (US, IT, altri)
```typescript
const VALID_PATTERNS = [
{ start: ';', end: '?' }, // Layout US
{ start: 'ò', end: '_' }, // Layout IT
];
```
- [ ] Rilevamento automatico pattern in uso
- [ ] Memorizzazione pattern attivo durante scan
- [ ] Validazione `end` solo per pattern corretto
- [ ] Logging avanzato con prefisso `[RFID]`
- [ ] Esportare info pattern attivo per debug page
### 6. Componenti UI (`components/`) ### 6. Componenti UI (`components/`)
- [x] `Logo.tsx` - logo Focolari - [x] `Logo.tsx` - logo Focolari
- [x] `Button.tsx` - varianti primary/secondary/danger - [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] `Modal.tsx` - modale base
- [x] `RFIDStatus.tsx` - indicatore stato scanner - [x] `RFIDStatus.tsx` - indicatore stato scanner
- [x] `UserCard.tsx` - card utente con foto e ruolo - [x] `UserCard.tsx` - card utente con foto e ruolo
- [x] `CountdownTimer.tsx` - timer con progress bar - [x] `CountdownTimer.tsx` - timer con progress bar
- [x] `index.ts` - barrel export - [x] `WelcomeCarousel.tsx` - carosello messaggi multilingua
- [ ] **DA FARE:** `WelcomeCarousel.tsx` - carosello messaggi multilingua - [x] `NumLockBanner.tsx` - avviso NumLock per desktop
### 7. Schermate (`screens/`) ### 7. Schermate (`screens/`)
- [x] `LoadingScreen.tsx` - caricamento iniziale - [x] `LoadingScreen.tsx` - caricamento iniziale + ping automatico
- [x] `ValidatorLoginScreen.tsx` - attesa badge + password - [x] `ValidatorLoginScreen.tsx` - attesa badge + password + NumLockBanner
- [x] `ActiveGateScreen.tsx` - varco attivo + scheda utente - [x] `ActiveGateScreen.tsx` - varco attivo:
- [x] `SuccessModal.tsx` - conferma ingresso fullscreen - [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] `ErrorModal.tsx` - errore fullscreen
- [x] `index.ts` - barrel export - [x] `DebugScreen.tsx` - pagina diagnostica RFID
- [ ] **DA FARE:** `DebugScreen.tsx` - pagina diagnostica RFID
### 8. State Machine (`App.tsx`) ### 8. State Machine (`App.tsx`)
- [x] Stati applicazione gestiti - [x] Stati applicazione gestiti
- [x] Integrazione `useRFIDScanner` - [x] Integrazione `useRFIDScanner`
- [x] Gestione sessione validatore (localStorage) - [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 sessione 30 minuti
- [x] Timeout utente 60 secondi - [x] Timeout utente 60 secondi
- [x] **Timeout badge non trovato 30 secondi**
- [x] Cambio rapido badge partecipante - [x] Cambio rapido badge partecipante
- [x] Conferma con badge validatore - [x] Conferma con badge validatore (quello della sessione)
- [ ] **DA FARE:** Logging transizioni con prefisso `[FLOW]` - [x] **Notifica se badge validatore rippassato senza utente**
- [x] Logging transizioni con prefisso `[FLOW]`
### 9. Modale Successo - Carosello Internazionale ### 9. Modale Successo - Carosello Internazionale
#### ⚠️ MODIFICA RICHIESTA - [x] Componente `WelcomeCarousel.tsx`
- [x] 10 lingue supportate
Il backend **NON** restituisce più messaggi multilingua. - [x] Scorrimento automatico ogni 800ms
Il frontend gestisce autonomamente la visualizzazione con un **carosello automatico**. - [x] Modale fullscreen verde
- [x] Durata totale: 5 secondi
**Specifiche:**
- [ ] Creare componente `WelcomeCarousel.tsx`
- [ ] Lista messaggi di benvenuto in diverse lingue:
```typescript
const WELCOME_MESSAGES = [
{ lang: 'it', text: 'Benvenuto!' },
{ lang: 'en', text: 'Welcome!' },
{ lang: 'fr', text: 'Bienvenue!' },
{ lang: 'de', text: 'Willkommen!' },
{ lang: 'es', text: 'Bienvenido!' },
{ lang: 'pt', text: 'Bem-vindo!' },
{ lang: 'zh', text: '欢迎!' },
{ lang: 'ja', text: 'ようこそ!' },
];
```
- [ ] Scorrimento automatico (es. ogni 2 secondi)
- [ ] Animazione transizione fluida (fade o slide)
- [ ] Modale fullscreen verde con carosello al centro
- [ ] Durata totale modale: 5 secondi
### 10. Debug & Diagnostica ### 10. Debug & Diagnostica
#### ⚠️ DA IMPLEMENTARE - [x] Pagina `/debug` dedicata
- [x] Logging console strutturato `[RFID]`, `[FLOW]`, `[API]`
- [ ] Pagina `/debug` dedicata
- [ ] Log ultimi 20 tasti premuti (key + code)
- [ ] Stato scanner real-time (idle/scanning)
- [ ] Buffer corrente
- [ ] Pattern attivo (US/IT/...)
- [ ] Ultimo codice rilevato
- [ ] Timestamp eventi
- [ ] Link/pulsante nascosto per accesso debug (es. logo cliccabile 5 volte)
- [ ] Logging console strutturato:
- [ ] `[RFID]` - eventi scanner
- [ ] `[FLOW]` - transizioni stato
- [ ] `[API]` - chiamate HTTP
### 11. Routing ### 11. Routing
- [ ] Installare React Router - [x] React Router
- [ ] Route principale `/` - [x] Route principale `/`
- [ ] Route debug `/debug` - [x] Route debug `/debug`
### 12. Test Automatici (E2E / Use Case Validation)
#### ⚠️ NUOVA SEZIONE
Sistema di test per validazione formale dei flussi e regression detection.
**Struttura:**
```
frontend/
├── src/
│ └── tests/
│ ├── usecase/
│ │ ├── UC01_ValidatorLogin.test.ts
│ │ ├── UC02_ParticipantAccess.test.ts
│ │ ├── UC03_DeniedAccess.test.ts
│ │ ├── UC04_SessionTimeout.test.ts
│ │ ├── UC05_QuickBadgeSwitch.test.ts
│ │ └── UC06_RFIDMultiPattern.test.ts
│ └── helpers/
│ ├── mockRFID.ts # Simulatore eventi tastiera RFID
│ └── testUtils.ts # Utility comuni
```
**Use Case da Testare:**
- [ ] **UC01 - Login Validatore**
- Simula badge validatore (`;999999?`)
- Inserisce password corretta
- Verifica transizione a stato `gate-active`
- Verifica sessione salvata in localStorage
- [ ] **UC02 - Accesso Partecipante Ammesso**
- Da stato `gate-active`
- Simula badge partecipante ammesso
- Verifica caricamento dati utente
- Simula badge validatore per conferma
- Verifica modale successo con carosello
- Verifica ritorno a `gate-active`
- [ ] **UC03 - Accesso Negato**
- Simula badge partecipante NON ammesso
- Verifica visualizzazione warning rosso
- Verifica che conferma validatore sia bloccata
- Verifica pulsante annulla funzionante
- [ ] **UC04 - Timeout Sessione**
- Verifica scadenza sessione 30 minuti
- Verifica redirect a login validatore
- Verifica pulizia localStorage
- [ ] **UC05 - Cambio Rapido Badge**
- Con utente a schermo
- Simula nuovo badge partecipante
- Verifica sostituzione immediata dati
- [ ] **UC06 - Pattern RFID Multipli**
- Testa pattern US (`;` / `?`)
- Testa pattern IT (`ò` / `_`)
- Verifica stesso risultato finale
**Dipendenze Test:**
```bash
npm install -D vitest @testing-library/react @testing-library/user-event jsdom
```
**Script npm:**
```json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
```
--- ---
## Correzioni Necessarie ## Badge di Test
### Hook RFID - Da Aggiornare | Badge | Nome | Ruolo | Ammesso |
|--------------|----------------|---------|---------------|
Il file `useRFIDScanner.ts` attualmente supporta **solo** il pattern US (`;` / `?`). | `0008988288` | Marco Bianchi | Votante | ✅ Sì |
| `0007399575` | Laura Rossi | Votante | ✅ Sì |
**Modifiche richieste:** | `0000514162` | Giuseppe Verdi | Tecnico | ❌ No |
| `0006478281` | - | - | ⚠️ Non nel DB |
1. Aggiungere costante `VALID_PATTERNS` con pattern multipli
2. Modificare logica `handleKeyDown` per:
- Riconoscere qualsiasi `start` sentinel
- Memorizzare quale pattern è in uso
- Validare solo con l'`end` corrispondente
3. Aggiungere stato `activePattern` per debug
4. Migliorare logging
### Success Modal - Da Aggiornare
Attualmente usa `welcome_message` dal backend.
**Modifiche richieste:**
1. Rimuovere dipendenza da `welcome_message` API
2. Implementare `WelcomeCarousel` con messaggi hardcoded
3. Carosello auto-scroll ogni 2 secondi
4. Animazioni fluide tra messaggi
### Logging Console
Attualmente i log usano messaggi generici. Aggiornare tutti i `console.log` con prefissi standardizzati.
--- ---
## Dipendenze da Aggiungere ## ✅ FRONTEND COMPLETATO
```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

View File

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

View File

@@ -4,14 +4,14 @@ verify_ssl = true
name = "pypi" name = "pypi"
[packages] [packages]
fastapi = ">=0.109.0" fastapi = "*"
uvicorn = {extras = ["standard"], version = ">=0.27.0"} uvicorn = "*"
pydantic = ">=2.5.0" pydantic = "*"
[dev-packages] [dev-packages]
[requires] [requires]
python_version = "3.10" python_version = "3.14"
[scripts] [scripts]
start = "python main.py" 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,277 +1,191 @@
""" """
Focolari Voting System - Backend Mock Focolari Voting System - Backend Mock
Sistema di controllo accessi per votazioni del Movimento dei Focolari 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 import argparse
from fastapi import FastAPI, HTTPException import json
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from fastapi.responses import FileResponse
from typing import Optional from fastapi.staticfiles import StaticFiles
app = FastAPI( from api.routes import router, init_data
title="Focolari Voting System API",
description="Backend mock per il sistema di controllo accessi",
version="1.0.0"
)
# CORS abilitato per tutti # Configurazione default
app.add_middleware( DEFAULT_PORT = 8000
CORSMiddleware, DEFAULT_HOST = "0.0.0.0"
allow_origins=["*"], DEFAULT_DATA = "data/users_default.json"
allow_credentials=True, STATIC_DIR = Path(__file__).parent.parent / "frontend" / "dist"
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================
# MODELLI PYDANTIC
# ============================================
class LoginRequest(BaseModel):
badge: str
password: str
class EntryRequest(BaseModel):
user_badge: str
validator_password: str
class UserResponse(BaseModel):
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: str
ammesso: bool
warning: Optional[str] = None
class RoomInfoResponse(BaseModel):
room_name: str
meeting_id: str
class LoginResponse(BaseModel):
success: bool
message: str
token: Optional[str] = None
class EntryResponse(BaseModel):
success: bool
message: str
welcome_message: Optional[str] = None
# ============================================
# DATI MOCK
# ============================================
# Credenziali Validatore
VALIDATOR_BADGE = "999999"
VALIDATOR_PASSWORD = "focolari"
MOCK_TOKEN = "focolare-validator-token-2024"
# Lista utenti mock
USERS_DB = [
{
"badge_code": "000001",
"nome": "Maria",
"cognome": "Rossi",
"url_foto": "https://randomuser.me/api/portraits/women/1.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000002",
"nome": "Giuseppe",
"cognome": "Bianchi",
"url_foto": "https://randomuser.me/api/portraits/men/2.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000003",
"nome": "Anna",
"cognome": "Verdi",
"url_foto": "https://randomuser.me/api/portraits/women/3.jpg",
"ruolo": "Tecnico",
"ammesso": True
},
{
"badge_code": "000004",
"nome": "Francesco",
"cognome": "Neri",
"url_foto": "https://randomuser.me/api/portraits/men/4.jpg",
"ruolo": "Ospite",
"ammesso": False # Non ammesso!
},
{
"badge_code": "000005",
"nome": "Lucia",
"cognome": "Gialli",
"url_foto": "https://randomuser.me/api/portraits/women/5.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000006",
"nome": "Paolo",
"cognome": "Blu",
"url_foto": "https://randomuser.me/api/portraits/men/6.jpg",
"ruolo": "Votante",
"ammesso": False # Non ammesso!
},
{
"badge_code": "123456",
"nome": "Teresa",
"cognome": "Martini",
"url_foto": "https://randomuser.me/api/portraits/women/7.jpg",
"ruolo": "Votante",
"ammesso": True
},
]
# Messaggi di benvenuto multilingua
WELCOME_MESSAGES = [
"Benvenuto! / Welcome!",
"Bienvenue! / Willkommen!",
"Bienvenido! / Bem-vindo!",
"欢迎! / 歓迎!",
"Добро пожаловать! / مرحبا!"
]
# ============================================
# ENDPOINTS
# ============================================
@app.get("/")
async def root():
"""Endpoint di test per verificare che il server sia attivo"""
return {"status": "ok", "message": "Focolari Voting System API is running"}
@app.get("/info-room", response_model=RoomInfoResponse) def load_data(data_path: str) -> dict:
async def get_room_info(): """Carica i dati dal file JSON"""
""" path = Path(data_path)
Restituisce le informazioni sulla sala e la riunione corrente.
""" if not path.is_absolute():
return RoomInfoResponse( # Path relativo alla directory del main.py
room_name="Sala Assemblea", base_dir = Path(__file__).parent
meeting_id="VOT-2024" 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 (utile in sviluppo)
@app.post("/login-validate", response_model=LoginResponse) app.add_middleware(
async def login_validate(request: LoginRequest): CORSMiddleware,
""" allow_origins=["*"],
Valida le credenziali del validatore. allow_credentials=True,
Il badge deve essere quello del validatore (999999) e la password corretta. allow_methods=["*"],
""" allow_headers=["*"],
# Pulisci il badge da eventuali caratteri sentinel
clean_badge = request.badge.strip().replace(";", "").replace("?", "")
if clean_badge != VALIDATOR_BADGE:
raise HTTPException(
status_code=401,
detail="Badge validatore non riconosciuto"
)
if request.password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password non corretta"
)
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=MOCK_TOKEN
) )
# Inizializza i dati nelle routes
init_data(data)
@app.get("/anagrafica/{badge_code}", response_model=UserResponse) # Registra le routes API
async def get_user_anagrafica(badge_code: str): app.include_router(router)
"""
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 # Serve frontend statico se la cartella esiste
for user in USERS_DB: if serve_frontend and STATIC_DIR.exists():
if user["badge_code"] == clean_badge or user["badge_code"].lstrip("0") == clean_badge.lstrip("0"): print(f"🌐 Frontend statico servito da: {STATIC_DIR}")
response = UserResponse(
badge_code=user["badge_code"],
nome=user["nome"],
cognome=user["cognome"],
url_foto=user["url_foto"],
ruolo=user["ruolo"],
ammesso=user["ammesso"]
)
# Aggiungi warning se non ammesso # Serve index.html per la root e tutte le route SPA
if not user["ammesso"]: @app.get("/")
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!" async def serve_index():
return FileResponse(STATIC_DIR / "index.html")
return response @app.get("/debug")
async def serve_debug():
return FileResponse(STATIC_DIR / "index.html")
# Utente non trovato # Monta i file statici (JS, CSS, assets) - DEVE essere dopo le route
raise HTTPException( app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
status_code=404,
detail=f"Utente con badge {clean_badge} non trovato nel sistema" # 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",
"room": data["room"]["room_name"],
"frontend": "not built - run 'npm run build' in frontend/"
}
return app
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
"""
) )
parser.add_argument(
@app.post("/entry-request", response_model=EntryResponse) "-p", "--port",
async def process_entry_request(request: EntryRequest): type=int,
""" default=DEFAULT_PORT,
Processa una richiesta di ingresso. help=f"Porta del server (default: {DEFAULT_PORT})"
Richiede il badge dell'utente e la password del validatore.
"""
# Pulisci i dati
clean_user_badge = request.user_badge.strip().replace(";", "").replace("?", "")
# Verifica password validatore
if request.validator_password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
# Cerca l'utente
user_found = None
for user in USERS_DB:
if user["badge_code"] == clean_user_badge or user["badge_code"].lstrip("0") == clean_user_badge.lstrip("0"):
user_found = user
break
if not user_found:
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_user_badge} non trovato"
)
if not user_found["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user_found['nome']} {user_found['cognome']} NON è autorizzato all'ingresso"
)
# Successo! Genera messaggio di benvenuto casuale
welcome = random.choice(WELCOME_MESSAGES)
return EntryResponse(
success=True,
message=f"Ingresso registrato per {user_found['nome']} {user_found['cognome']}",
welcome_message=welcome
) )
parser.add_argument(
"-d", "--data",
type=str,
default=DEFAULT_DATA,
help=f"Path al file JSON con i dati (default: {DEFAULT_DATA})"
)
parser.add_argument(
"--host",
type=str,
default=DEFAULT_HOST,
help=f"Host di binding (default: {DEFAULT_HOST})"
)
parser.add_argument(
"--api-only",
action="store_true",
help="Avvia solo le API senza servire il frontend"
)
return parser.parse_args()
def main():
"""Entry point principale"""
args = parse_args()
print("🚀 Avvio Focolari Voting System Backend...")
print("=" * 50)
# Carica i dati
data = load_data(args.data)
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)
# Crea e avvia l'app
app = create_app(data, serve_frontend=not args.api_only)
uvicorn.run(app, host=args.host, port=args.port)
# ============================================
# AVVIO SERVER
# ============================================
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn main()
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)

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: 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](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react)
- [@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 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 ## 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 ## 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 ```js
// eslint.config.js // eslint.config.js

View File

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

View File

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

View File

@@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -2265,6 +2266,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3496,6 +3510,44 @@
"node": ">=0.10.0" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3567,6 +3619,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@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,374 +3,400 @@
* State Machine per il controllo accessi * State Machine per il controllo accessi
*/ */
import { useState, useEffect, useCallback } from 'react'; import {useCallback, useEffect, useState} from 'react';
import { useRFIDScanner } from './hooks/useRFIDScanner'; import {useRFIDScanner} from './hooks/useRFIDScanner';
import { import {ActiveGateScreen, ErrorModal, LoadingScreen, SuccessModal, ValidatorLoginScreen,} from './screens';
LoadingScreen, import {ApiError, getRoomInfo, getUserByBadge, loginValidator, requestEntry,} from './services/api';
ValidatorLoginScreen, import type {AppState, RoomInfo, User, ValidatorSession} from './types';
ActiveGateScreen,
SuccessModal,
ErrorModal,
} from './screens';
import {
getRoomInfo,
loginValidator,
getUserByBadge,
requestEntry,
ApiError,
} from './services/api';
import type { AppState, RoomInfo, User, ValidatorSession } from './types';
// Costanti // Costanti
const VALIDATOR_BADGE = '999999';
const VALIDATOR_PASSWORD = 'focolari';
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti
const USER_TIMEOUT_SECONDS = 60; const USER_TIMEOUT_SECONDS = 60;
const STORAGE_KEY = 'focolari_validator_session'; const STORAGE_KEY = 'focolari_validator_session';
function App() { function App() {
// ============================================ // ============================================
// State // State
// ============================================ // ============================================
const [appState, setAppState] = useState<AppState>('loading'); const [appState, setAppState] = useState<AppState>('loading');
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null); const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
const [validatorSession, setValidatorSession] = useState<ValidatorSession | null>(null); const [validatorSession, setValidatorSession] = useState<ValidatorSession | null>(null);
const [pendingValidatorBadge, setPendingValidatorBadge] = useState<string | null>(null); const [pendingValidatorBadge, setPendingValidatorBadge] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Modal states // Modal states
const [showSuccessModal, setShowSuccessModal] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successMessage, setSuccessMessage] = useState(''); const [successUserName, setSuccessUserName] = useState<string | undefined>(undefined);
const [showErrorModal, setShowErrorModal] = useState(false); const [showErrorModal, setShowErrorModal] = useState(false);
const [errorModalMessage, setErrorModalMessage] = useState(''); const [errorModalMessage, setErrorModalMessage] = useState('');
// ============================================ // Notifica badge validatore ignorato
// Session Management const [showValidatorBadgeNotice, setShowValidatorBadgeNotice] = useState(false);
// ============================================
const saveSession = useCallback((session: ValidatorSession) => { // Badge non trovato (con timeout per tornare all'attesa)
localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); const [notFoundBadge, setNotFoundBadge] = useState<string | null>(null);
setValidatorSession(session);
}, []);
const clearSession = useCallback(() => { // ============================================
localStorage.removeItem(STORAGE_KEY); // Session Management
setValidatorSession(null); // ============================================
setPendingValidatorBadge(null);
setCurrentUser(null);
setAppState('waiting-validator');
}, []);
const loadSession = useCallback((): ValidatorSession | null => { const saveSession = useCallback((session: ValidatorSession) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
const stored = localStorage.getItem(STORAGE_KEY); setValidatorSession(session);
if (!stored) return null; }, []);
const session: ValidatorSession = JSON.parse(stored); const clearSession = useCallback(() => {
// Check if session is expired
if (Date.now() > session.expiresAt) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
return null; setValidatorSession(null);
} setPendingValidatorBadge(null);
return session;
} catch {
return null;
}
}, []);
// ============================================
// RFID Scanner Handler
// ============================================
const handleRFIDScan = useCallback(async (code: string) => {
console.log('[App] Badge scansionato:', code);
// Pulisci il codice
const cleanCode = code.trim();
switch (appState) {
case 'waiting-validator':
// Verifica se è un badge validatore
if (cleanCode === VALIDATOR_BADGE) {
setPendingValidatorBadge(cleanCode);
setAppState('validator-password');
} else {
setError('Badge validatore non riconosciuto');
setTimeout(() => setError(undefined), 3000);
}
break;
case 'validator-password':
// Ignora badge durante inserimento password
break;
case 'gate-active':
case 'showing-user':
// Se è il badge del validatore e c'è un utente a schermo
if (cleanCode === VALIDATOR_BADGE && currentUser && currentUser.ammesso) {
// Conferma ingresso
await handleEntryConfirm();
} else if (cleanCode !== VALIDATOR_BADGE) {
// Nuovo badge partecipante - carica utente
await handleLoadUser(cleanCode);
}
break;
default:
break;
}
}, [appState, currentUser]);
// ============================================
// Initialize RFID Scanner
// ============================================
const { state: rfidState, buffer: rfidBuffer } = useRFIDScanner({
onScan: handleRFIDScan,
onTimeout: () => {
console.warn('[App] RFID timeout - lettura incompleta');
},
disabled: appState === 'loading',
});
// ============================================
// API Handlers
// ============================================
const handleLoadUser = useCallback(async (badgeCode: string) => {
setLoading(true);
setError(undefined);
setAppState('showing-user');
try {
const user = await getUserByBadge(badgeCode);
setCurrentUser(user);
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante il caricamento utente';
setError(message);
setCurrentUser(null);
} finally {
setLoading(false);
}
}, []);
const handleEntryConfirm = useCallback(async () => {
if (!currentUser || !validatorSession) return;
setLoading(true);
try {
const response = await requestEntry(
currentUser.badge_code,
VALIDATOR_PASSWORD
);
if (response.success) {
setSuccessMessage(response.welcome_message || 'Benvenuto!');
setShowSuccessModal(true);
setCurrentUser(null); setCurrentUser(null);
setAppState('waiting-validator');
}, []);
const loadSession = useCallback((serverStartTime?: number): ValidatorSession | null => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const session: ValidatorSession = JSON.parse(stored);
// Check if session is expired
if (Date.now() > session.expiresAt) {
console.log('[FLOW] Session expired by time');
localStorage.removeItem(STORAGE_KEY);
return null;
}
// Check if server restarted (invalidate old sessions)
if (serverStartTime && session.serverStartTime !== serverStartTime) {
console.log('[FLOW] Session invalidated - server restarted');
localStorage.removeItem(STORAGE_KEY);
return null;
}
return session;
} catch {
return null;
}
}, []);
// ============================================
// RFID Scanner Handler
// ============================================
const handleRFIDScan = useCallback(async (code: string) => {
console.log('[RFID] Badge scansionato:', code);
// Pulisci il codice
const cleanCode = code.trim();
switch (appState) {
case 'waiting-validator':
// Qualsiasi badge può essere un validatore - verrà verificato con la password
console.log('[FLOW] Transition: waiting-validator -> validator-password');
setPendingValidatorBadge(cleanCode);
setAppState('validator-password');
break;
case 'validator-password':
// Ignora badge durante inserimento password
console.log('[FLOW] Badge ignorato durante inserimento password');
break;
case 'gate-active':
case 'showing-user':
// Se è il badge del validatore attuale
if (validatorSession && cleanCode === validatorSession.badge) {
if (currentUser && currentUser.ammesso) {
console.log('[FLOW] Validator badge detected - confirming entry');
// Conferma ingresso
await handleEntryConfirm();
} else {
// Badge validatore passato senza utente ammesso - mostra notifica
console.log('[FLOW] Validator badge ignored - showing notice');
setShowValidatorBadgeNotice(true);
setTimeout(() => setShowValidatorBadgeNotice(false), 4000);
}
} else {
console.log('[FLOW] Loading participant:', cleanCode);
// Badge partecipante - carica utente
// Se c'era un badge non trovato, cancellalo e carica il nuovo
setNotFoundBadge(null);
await handleLoadUser(cleanCode);
}
break;
default:
break;
}
}, [appState, currentUser, validatorSession]);
// ============================================
// Initialize RFID Scanner
// ============================================
const {state: rfidState, buffer: rfidBuffer} = useRFIDScanner({
onScan: handleRFIDScan,
onTimeout: () => {
console.warn('[App] RFID timeout - lettura incompleta');
},
disabled: appState === 'loading',
});
// ============================================
// API Handlers
// ============================================
const handleLoadUser = useCallback(async (badgeCode: string) => {
setLoading(true);
setError(undefined);
setNotFoundBadge(null);
setAppState('showing-user');
try {
const user = await getUserByBadge(badgeCode);
setCurrentUser(user);
} catch (err) {
if (err instanceof ApiError && err.statusCode === 404) {
// Utente non trovato - mostra schermata speciale con timeout
console.log('[FLOW] Badge not found:', badgeCode);
setNotFoundBadge(badgeCode);
setCurrentUser(null);
} else {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante il caricamento utente';
setError(message);
setCurrentUser(null);
}
} finally {
setLoading(false);
}
}, []);
const handleEntryConfirm = useCallback(async () => {
if (!currentUser || !validatorSession) return;
setLoading(true);
// Salva il nome utente prima di pulirlo
const userName = `${currentUser.nome} ${currentUser.cognome}`;
try {
const response = await requestEntry(
currentUser.badge_code,
validatorSession.password // Uso la password dalla sessione
);
if (response.success) {
console.log('[FLOW] Entry confirmed for:', userName);
setSuccessUserName(userName);
setShowSuccessModal(true);
setCurrentUser(null);
setAppState('gate-active');
}
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante la registrazione ingresso';
setErrorModalMessage(message);
setShowErrorModal(true);
} finally {
setLoading(false);
}
}, [currentUser, validatorSession]);
const handlePasswordSubmit = useCallback(async (password: string) => {
if (!pendingValidatorBadge || !roomInfo) return;
setLoading(true);
setError(undefined);
try {
const response = await loginValidator(pendingValidatorBadge, password);
if (response.success) {
console.log('[FLOW] Validator authenticated, badge:', pendingValidatorBadge);
const session: ValidatorSession = {
badge: pendingValidatorBadge,
password: password, // Salvo la password per le conferme ingresso
token: response.token || '',
loginTime: Date.now(),
expiresAt: Date.now() + SESSION_DURATION_MS,
serverStartTime: roomInfo.server_start_time, // Per invalidare se server riparte
};
saveSession(session);
setPendingValidatorBadge(null);
setAppState('gate-active');
}
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante il login';
setError(message);
} finally {
setLoading(false);
}
}, [pendingValidatorBadge, saveSession, roomInfo]);
// ============================================
// UI Handlers
// ============================================
const handleCancelPassword = useCallback(() => {
setPendingValidatorBadge(null);
setError(undefined);
setAppState('waiting-validator');
}, []);
const handleCancelUser = useCallback(() => {
setCurrentUser(null);
setNotFoundBadge(null);
setError(undefined);
setAppState('gate-active'); setAppState('gate-active');
} }, []);
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Errore durante la registrazione ingresso';
setErrorModalMessage(message);
setShowErrorModal(true);
} finally {
setLoading(false);
}
}, [currentUser, validatorSession]);
const handlePasswordSubmit = useCallback(async (password: string) => { const handleUserTimeout = useCallback(() => {
if (!pendingValidatorBadge) return; console.log('[App] User timeout - tornando in attesa');
setCurrentUser(null);
setNotFoundBadge(null);
setError(undefined);
setAppState('gate-active');
}, []);
setLoading(true); const handleSuccessModalClose = useCallback(() => {
setError(undefined); setShowSuccessModal(false);
setSuccessUserName(undefined);
}, []);
try { const handleErrorModalClose = useCallback(() => {
const response = await loginValidator(pendingValidatorBadge, password); setShowErrorModal(false);
setErrorModalMessage('');
}, []);
if (response.success) { // ============================================
const session: ValidatorSession = { // Initialization
badge: pendingValidatorBadge, // ============================================
token: response.token || '',
loginTime: Date.now(), useEffect(() => {
expiresAt: Date.now() + SESSION_DURATION_MS, const init = async () => {
try {
// Carica info sala
const info = await getRoomInfo();
setRoomInfo(info);
// Verifica sessione esistente (passa serverStartTime per invalidazione)
const existingSession = loadSession(info.server_start_time);
if (existingSession) {
setValidatorSession(existingSession);
setAppState('gate-active');
} else {
setAppState('waiting-validator');
}
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Impossibile connettersi al server';
setError(message);
}
}; };
saveSession(session); init();
setPendingValidatorBadge(null); }, [loadSession]);
setAppState('gate-active');
} // Session expiry check
} catch (err) { useEffect(() => {
const message = err instanceof ApiError if (!validatorSession) return;
? err.detail || err.message
: 'Errore durante il login'; const checkExpiry = () => {
setError(message); if (Date.now() > validatorSession.expiresAt) {
} finally { clearSession();
setLoading(false); }
};
const interval = setInterval(checkExpiry, 60000); // Check every minute
return () => clearInterval(interval);
}, [validatorSession, clearSession]);
// ============================================
// Render
// ============================================
// Loading state
if (appState === 'loading') {
return (
<LoadingScreen
message="Connessione al server..."
error={error}
onRetry={() => window.location.reload()}
/>
);
} }
}, [pendingValidatorBadge, saveSession]);
// ============================================ // No room info - error
// UI Handlers if (!roomInfo) {
// ============================================ return (
<LoadingScreen
error={error || 'Impossibile caricare le informazioni della sala'}
onRetry={() => window.location.reload()}
/>
);
}
const handleCancelPassword = useCallback(() => { // Validator login screens
setPendingValidatorBadge(null); if (appState === 'waiting-validator' || appState === 'validator-password') {
setError(undefined); return (
setAppState('waiting-validator'); <ValidatorLoginScreen
}, []); roomInfo={roomInfo}
rfidState={rfidState}
rfidBuffer={rfidBuffer}
validatorBadge={pendingValidatorBadge}
onPasswordSubmit={handlePasswordSubmit}
onCancel={handleCancelPassword}
error={error}
loading={loading}
/>
);
}
const handleCancelUser = useCallback(() => { // Active gate screen
setCurrentUser(null);
setError(undefined);
setAppState('gate-active');
}, []);
const handleUserTimeout = useCallback(() => {
console.log('[App] User timeout - tornando in attesa');
setCurrentUser(null);
setError(undefined);
setAppState('gate-active');
}, []);
const handleSuccessModalClose = useCallback(() => {
setShowSuccessModal(false);
setSuccessMessage('');
}, []);
const handleErrorModalClose = useCallback(() => {
setShowErrorModal(false);
setErrorModalMessage('');
}, []);
// ============================================
// Initialization
// ============================================
useEffect(() => {
const init = async () => {
try {
// Carica info sala
const info = await getRoomInfo();
setRoomInfo(info);
// Verifica sessione esistente
const existingSession = loadSession();
if (existingSession) {
setValidatorSession(existingSession);
setAppState('gate-active');
} else {
setAppState('waiting-validator');
}
} catch (err) {
const message = err instanceof ApiError
? err.detail || err.message
: 'Impossibile connettersi al server';
setError(message);
}
};
init();
}, [loadSession]);
// Session expiry check
useEffect(() => {
if (!validatorSession) return;
const checkExpiry = () => {
if (Date.now() > validatorSession.expiresAt) {
clearSession();
}
};
const interval = setInterval(checkExpiry, 60000); // Check every minute
return () => clearInterval(interval);
}, [validatorSession, clearSession]);
// ============================================
// Render
// ============================================
// Loading state
if (appState === 'loading') {
return ( return (
<LoadingScreen <>
message="Connessione al server..." <ActiveGateScreen
error={error} roomInfo={roomInfo}
onRetry={() => window.location.reload()} rfidState={rfidState}
/> rfidBuffer={rfidBuffer}
currentUser={currentUser}
notFoundBadge={notFoundBadge}
loading={loading}
error={error}
onCancelUser={handleCancelUser}
onLogout={clearSession}
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
onUserTimeout={handleUserTimeout}
showValidatorBadgeNotice={showValidatorBadgeNotice}
/>
{/* Success Modal */}
<SuccessModal
isOpen={showSuccessModal}
onClose={handleSuccessModalClose}
userName={successUserName}
/>
{/* Error Modal */}
<ErrorModal
isOpen={showErrorModal}
onClose={handleErrorModalClose}
message={errorModalMessage}
/>
</>
); );
}
// No room info - error
if (!roomInfo) {
return (
<LoadingScreen
error={error || 'Impossibile caricare le informazioni della sala'}
onRetry={() => window.location.reload()}
/>
);
}
// Validator login screens
if (appState === 'waiting-validator' || appState === 'validator-password') {
return (
<ValidatorLoginScreen
roomInfo={roomInfo}
rfidState={rfidState}
rfidBuffer={rfidBuffer}
validatorBadge={pendingValidatorBadge}
onPasswordSubmit={handlePasswordSubmit}
onCancel={handleCancelPassword}
error={error}
loading={loading}
/>
);
}
// Active gate screen
return (
<>
<ActiveGateScreen
roomInfo={roomInfo}
rfidState={rfidState}
rfidBuffer={rfidBuffer}
currentUser={currentUser}
loading={loading}
error={error}
onCancelUser={handleCancelUser}
onLogout={clearSession}
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
onUserTimeout={handleUserTimeout}
/>
{/* Success Modal */}
<SuccessModal
isOpen={showSuccessModal}
onClose={handleSuccessModalClose}
welcomeMessage={successMessage}
userName={currentUser ? `${currentUser.nome} ${currentUser.cognome}` : undefined}
/>
{/* Error Modal */}
<ErrorModal
isOpen={showErrorModal}
onClose={handleErrorModalClose}
message={errorModalMessage}
/>
</>
);
} }
export default App; export default App;

View File

@@ -3,88 +3,88 @@
*/ */
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'success'; variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean; fullWidth?: boolean;
loading?: boolean; loading?: boolean;
} }
export function Button({ export function Button({
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
fullWidth = false, fullWidth = false,
loading = false, loading = false,
disabled, disabled,
children, children,
className = '', className = '',
...props ...props
}: ButtonProps) { }: ButtonProps) {
const baseClasses = const baseClasses =
'font-semibold rounded-xl transition-all duration-200 ' + 'font-semibold rounded-xl transition-all duration-200 ' +
'focus:outline-none focus:ring-4 focus:ring-offset-2 ' + 'focus:outline-none focus:ring-4 focus:ring-offset-2 ' +
'disabled:opacity-50 disabled:cursor-not-allowed ' + 'disabled:opacity-50 disabled:cursor-not-allowed ' +
'active:scale-95 touch-target'; 'active:scale-95 touch-target';
const variantClasses = { const variantClasses = {
primary: primary:
'bg-focolare-blue hover:bg-focolare-blue-dark text-white ' + 'bg-focolare-blue hover:bg-focolare-blue-dark text-white ' +
'focus:ring-focolare-blue/50', 'focus:ring-focolare-blue/50',
secondary: secondary:
'bg-gray-200 hover:bg-gray-300 text-gray-800 ' + 'bg-gray-200 hover:bg-gray-300 text-gray-800 ' +
'focus:ring-gray-400/50', 'focus:ring-gray-400/50',
danger: danger:
'bg-error hover:bg-error-dark text-white ' + 'bg-error hover:bg-error-dark text-white ' +
'focus:ring-error/50', 'focus:ring-error/50',
success: success:
'bg-success hover:bg-success-dark text-white ' + 'bg-success hover:bg-success-dark text-white ' +
'focus:ring-success/50', 'focus:ring-success/50',
}; };
const sizeClasses = { const sizeClasses = {
sm: 'px-4 py-2 text-sm', sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base', md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg', lg: 'px-8 py-4 text-lg',
}; };
const widthClass = fullWidth ? 'w-full' : ''; const widthClass = fullWidth ? 'w-full' : '';
return ( return (
<button <button
className={` className={`
${baseClasses} ${baseClasses}
${variantClasses[variant]} ${variantClasses[variant]}
${sizeClasses[size]} ${sizeClasses[size]}
${widthClass} ${widthClass}
${className} ${className}
`.trim()} `.trim()}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
{loading ? ( {loading ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"> <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle <circle
className="opacity-25" className="opacity-25"
cx="12" cx="12"
cy="12" cy="12"
r="10" r="10"
stroke="currentColor" stroke="currentColor"
strokeWidth="4" strokeWidth="4"
fill="none" fill="none"
/> />
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/> />
</svg> </svg>
Caricamento... Caricamento...
</span> </span>
) : ( ) : (
children children
)} )}
</button> </button>
); );
} }
export default Button; export default Button;

View File

@@ -2,100 +2,100 @@
* Countdown Timer Component - Focolari Voting System * Countdown Timer Component - Focolari Voting System
*/ */
import { useState, useEffect, useCallback } from 'react'; import {useCallback, useEffect, useState} from 'react';
interface CountdownTimerProps { interface CountdownTimerProps {
/** Secondi totali */ /** Secondi totali */
seconds: number; seconds: number;
/** Callback quando il timer scade */ /** Callback quando il timer scade */
onExpire: () => void; onExpire: () => void;
/** Pausa il timer */ /** Pausa il timer */
paused?: boolean; paused?: boolean;
/** Mostra come barra di progresso */ /** Mostra come barra di progresso */
showBar?: boolean; showBar?: boolean;
/** Soglia warning (colore giallo) */ /** Soglia warning (colore giallo) */
warningThreshold?: number; warningThreshold?: number;
/** Soglia danger (colore rosso) */ /** Soglia danger (colore rosso) */
dangerThreshold?: number; dangerThreshold?: number;
} }
export function CountdownTimer({ export function CountdownTimer({
seconds, seconds,
onExpire, onExpire,
paused = false, paused = false,
showBar = true, showBar = true,
warningThreshold = 30, warningThreshold = 30,
dangerThreshold = 10, dangerThreshold = 10,
}: CountdownTimerProps) { }: CountdownTimerProps) {
const [remaining, setRemaining] = useState(seconds); const [remaining, setRemaining] = useState(seconds);
const formatTime = useCallback((secs: number): string => { const formatTime = useCallback((secs: number): string => {
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
const secsLeft = secs % 60; const secsLeft = secs % 60;
return `${mins}:${secsLeft.toString().padStart(2, '0')}`; return `${mins}:${secsLeft.toString().padStart(2, '0')}`;
}, []); }, []);
useEffect(() => { useEffect(() => {
setRemaining(seconds); setRemaining(seconds);
}, [seconds]); }, [seconds]);
useEffect(() => { useEffect(() => {
if (paused || remaining <= 0) { if (paused || remaining <= 0) {
return; return;
}
const timer = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(timer);
onExpire();
return 0;
} }
return prev - 1;
});
}, 1000);
return () => clearInterval(timer); const timer = setInterval(() => {
}, [paused, remaining, onExpire]); setRemaining((prev) => {
if (prev <= 1) {
clearInterval(timer);
onExpire();
return 0;
}
return prev - 1;
});
}, 1000);
const getColorClass = (): string => { return () => clearInterval(timer);
if (remaining <= dangerThreshold) { }, [paused, remaining, onExpire]);
return 'text-error';
}
if (remaining <= warningThreshold) {
return 'text-warning';
}
return 'text-focolare-blue';
};
const getBarColorClass = (): string => { const getColorClass = (): string => {
if (remaining <= dangerThreshold) { if (remaining <= dangerThreshold) {
return 'bg-error'; return 'text-error';
} }
if (remaining <= warningThreshold) { if (remaining <= warningThreshold) {
return 'bg-warning'; return 'text-warning';
} }
return 'bg-focolare-blue'; return 'text-focolare-blue';
}; };
const progressPercent = (remaining / seconds) * 100; const getBarColorClass = (): string => {
if (remaining <= dangerThreshold) {
return 'bg-error';
}
if (remaining <= warningThreshold) {
return 'bg-warning';
}
return 'bg-focolare-blue';
};
return ( const progressPercent = (remaining / seconds) * 100;
<div className="flex flex-col items-center gap-2">
<div className={`text-2xl font-bold font-mono ${getColorClass()}`}>
{formatTime(remaining)}
</div>
{showBar && ( return (
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden"> <div className="flex flex-col items-center gap-2">
<div <div className={`text-2xl font-bold font-mono ${getColorClass()}`}>
className={`h-full transition-all duration-1000 ease-linear ${getBarColorClass()}`} {formatTime(remaining)}
style={{ width: `${progressPercent}%` }} </div>
/>
{showBar && (
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-1000 ease-linear ${getBarColorClass()}`}
style={{width: `${progressPercent}%`}}
/>
</div>
)}
</div> </div>
)} );
</div>
);
} }
export default CountdownTimer; export default CountdownTimer;

View File

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

View File

@@ -5,40 +5,40 @@
import FocolareLogo from '../assets/FocolareMovLogo.jpg'; import FocolareLogo from '../assets/FocolareMovLogo.jpg';
interface LogoProps { interface LogoProps {
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
showText?: boolean; showText?: boolean;
} }
export function Logo({ size = 'md', showText = true }: LogoProps) { export function Logo({size = 'md', showText = true}: LogoProps) {
const sizeClasses = { const sizeClasses = {
sm: 'h-10 w-10', sm: 'h-10 w-10',
md: 'h-14 w-14', md: 'h-14 w-14',
lg: 'h-20 w-20', lg: 'h-20 w-20',
}; };
const textSizeClasses = { const textSizeClasses = {
sm: 'text-lg', sm: 'text-lg',
md: 'text-xl', md: 'text-xl',
lg: 'text-2xl', lg: 'text-2xl',
}; };
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <img
src={FocolareLogo} src={FocolareLogo}
alt="Movimento dei Focolari" alt="Movimento dei Focolari"
className={`${sizeClasses[size]} rounded-lg object-contain shadow-md`} className={`${sizeClasses[size]} rounded-lg object-contain shadow-md`}
/> />
{showText && ( {showText && (
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`${textSizeClasses[size]} font-bold text-focolare-blue`}> <span className={`${textSizeClasses[size]} font-bold text-focolare-blue`}>
Movimento dei Focolari Movimento dei Focolari
</span> </span>
<span className="text-sm text-gray-500">Sistema Votazioni</span> <span className="text-sm text-gray-500">Sistema Votazioni</span>
</div>
)}
</div> </div>
)} );
</div>
);
} }
export default Logo; export default Logo;

View File

@@ -2,79 +2,79 @@
* Modal Component - Focolari Voting System * Modal Component - Focolari Voting System
*/ */
import { useEffect } from 'react'; import {useEffect} from 'react';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose?: () => void; onClose?: () => void;
variant?: 'success' | 'error' | 'info'; variant?: 'success' | 'error' | 'info';
autoCloseMs?: number; autoCloseMs?: number;
fullscreen?: boolean; fullscreen?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
export function Modal({ export function Modal({
isOpen, isOpen,
onClose, onClose,
variant = 'info', variant = 'info',
autoCloseMs, autoCloseMs,
fullscreen = false, fullscreen = false,
children, children,
}: ModalProps) { }: ModalProps) {
// Auto-close functionality // Auto-close functionality
useEffect(() => { useEffect(() => {
if (!isOpen || !autoCloseMs || !onClose) { if (!isOpen || !autoCloseMs || !onClose) {
return; return;
}
const timer = setTimeout(() => {
onClose();
}, autoCloseMs);
return () => clearTimeout(timer);
}, [isOpen, autoCloseMs, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) {
return null;
} }
const timer = setTimeout(() => { const variantClasses = {
onClose(); success: 'bg-success',
}, autoCloseMs); error: 'bg-error',
info: 'bg-focolare-blue',
return () => clearTimeout(timer);
}, [isOpen, autoCloseMs, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
}; };
}, [isOpen]);
if (!isOpen) { const overlayClass = fullscreen
return null; ? `fixed inset-0 z-50 ${variantClasses[variant]} animate-fade-in`
} : 'fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fade-in';
const variantClasses = { const contentClass = fullscreen
success: 'bg-success', ? 'h-full w-full flex items-center justify-center'
error: 'bg-error', : 'glass rounded-2xl shadow-2xl max-w-lg w-full p-6 animate-slide-up';
info: 'bg-focolare-blue',
};
const overlayClass = fullscreen return (
? `fixed inset-0 z-50 ${variantClasses[variant]} animate-fade-in` <div className={overlayClass} onClick={onClose}>
: 'fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fade-in'; <div
className={contentClass}
const contentClass = fullscreen onClick={(e) => e.stopPropagation()}
? 'h-full w-full flex items-center justify-center' >
: 'glass rounded-2xl shadow-2xl max-w-lg w-full p-6 animate-slide-up'; {children}
</div>
return ( </div>
<div className={overlayClass} onClick={onClose}> );
<div
className={contentClass}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
} }
export default Modal; export default Modal;

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

@@ -3,31 +3,31 @@
* Mostra lo stato del lettore RFID * Mostra lo stato del lettore RFID
*/ */
import type { RFIDScannerState } from '../types'; import type {RFIDScannerState} from '../types';
interface RFIDStatusProps { interface RFIDStatusProps {
state: RFIDScannerState; state: RFIDScannerState;
buffer?: string; buffer?: string;
} }
export function RFIDStatus({ state, buffer }: RFIDStatusProps) { export function RFIDStatus({state, buffer}: RFIDStatusProps) {
if (state === 'idle') { if (state === 'idle') {
return ( return (
<div className="flex items-center gap-2 text-gray-400"> <div className="flex items-center gap-2 text-gray-400">
<div className="h-3 w-3 rounded-full bg-gray-300" /> <div className="h-3 w-3 rounded-full bg-gray-300"/>
<span className="text-sm">RFID Pronto</span> <span className="text-sm">RFID Pronto</span>
</div> </div>
); );
} }
return ( return (
<div className="flex items-center gap-2 text-focolare-orange"> <div className="flex items-center gap-2 text-focolare-orange">
<div className="h-3 w-3 rounded-full bg-focolare-orange animate-pulse" /> <div className="h-3 w-3 rounded-full bg-focolare-orange animate-pulse"/>
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
Lettura in corso... {buffer && `(${buffer.length} caratteri)`} Lettura in corso... {buffer && `(${buffer.length} caratteri)`}
</span> </span>
</div> </div>
); );
} }
export default RFIDStatus; export default RFIDStatus;

View File

@@ -2,99 +2,99 @@
* User Card Component - Focolari Voting System * User Card Component - Focolari Voting System
*/ */
import type { User } from '../types'; import type {User} from '../types';
interface UserCardProps { interface UserCardProps {
user: User; user: User;
size?: 'compact' | 'full'; size?: 'compact' | 'full';
} }
export function UserCard({ user, size = 'full' }: UserCardProps) { export function UserCard({user, size = 'full'}: UserCardProps) {
const roleColors: Record<string, string> = { const roleColors: Record<string, string> = {
Votante: 'bg-focolare-blue text-white', Votante: 'bg-focolare-blue text-white',
Tecnico: 'bg-focolare-orange text-white', Tecnico: 'bg-focolare-orange text-white',
Ospite: 'bg-gray-500 text-white', Ospite: 'bg-gray-500 text-white',
}; };
const statusClass = user.ammesso const statusClass = user.ammesso
? 'border-success bg-success/10' ? 'border-success bg-success/10'
: 'border-error bg-error/10 animate-pulse-error'; : 'border-error bg-error/10 animate-pulse-error';
if (size === 'compact') { if (size === 'compact') {
return ( return (
<div className={`rounded-xl border-2 p-4 ${statusClass} animate-slide-up`}> <div className={`rounded-xl border-2 p-4 ${statusClass} animate-slide-up`}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img <img
src={user.url_foto} src={user.url_foto}
alt={`${user.nome} ${user.cognome}`} alt={`${user.nome} ${user.cognome}`}
className="h-16 w-16 rounded-full object-cover shadow-md" className="h-16 w-16 rounded-full object-cover shadow-md"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = (e.target as HTMLImageElement).src =
'https://via.placeholder.com/100?text=' + user.nome.charAt(0); 'https://via.placeholder.com/100?text=' + user.nome.charAt(0);
}} }}
/> />
<div> <div>
<h3 className="text-lg font-bold text-gray-800"> <h3 className="text-lg font-bold text-gray-800">
{user.nome} {user.cognome} {user.nome} {user.cognome}
</h3> </h3>
<span className={`inline-block px-2 py-1 text-sm rounded ${roleColors[user.ruolo]}`}> <span className={`inline-block px-2 py-1 text-sm rounded ${roleColors[user.ruolo]}`}>
{user.ruolo} {user.ruolo}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className={`rounded-2xl border-4 p-6 ${statusClass} animate-slide-up`}> <div className={`rounded-2xl border-4 p-6 ${statusClass} animate-slide-up`}>
{/* Foto e Dati Principali */} {/* Foto e Dati Principali */}
<div className="flex flex-col items-center gap-4 md:flex-row md:items-start md:gap-6"> <div className="flex flex-col items-center gap-4 md:flex-row md:items-start md:gap-6">
<img <img
src={user.url_foto} src={user.url_foto}
alt={`${user.nome} ${user.cognome}`} alt={`${user.nome} ${user.cognome}`}
className="h-32 w-32 rounded-2xl object-cover shadow-lg md:h-40 md:w-40" className="h-32 w-32 rounded-2xl object-cover shadow-lg md:h-40 md:w-40"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = (e.target as HTMLImageElement).src =
'https://via.placeholder.com/200?text=' + user.nome.charAt(0); 'https://via.placeholder.com/200?text=' + user.nome.charAt(0);
}} }}
/> />
<div className="flex flex-col items-center text-center md:items-start md:text-left"> <div className="flex flex-col items-center text-center md:items-start md:text-left">
<h2 className="text-3xl font-bold text-gray-800 md:text-4xl"> <h2 className="text-3xl font-bold text-gray-800 md:text-4xl">
{user.nome} {user.cognome} {user.nome} {user.cognome}
</h2> </h2>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${roleColors[user.ruolo]}`}> <span className={`px-4 py-2 text-lg font-semibold rounded-full ${roleColors[user.ruolo]}`}>
{user.ruolo} {user.ruolo}
</span> </span>
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${ <span className={`px-4 py-2 text-lg font-semibold rounded-full ${
user.ammesso user.ammesso
? 'bg-success text-white' ? 'bg-success text-white'
: 'bg-error text-white animate-blink' : 'bg-error text-white animate-blink'
}`}> }`}>
{user.ammesso ? '✓ AMMESSO' : '✗ NON AMMESSO'} {user.ammesso ? '✓ AMMESSO' : '✗ NON AMMESSO'}
</span> </span>
</div> </div>
<p className="mt-3 text-gray-500"> <p className="mt-3 text-gray-500">
Badge: <span className="font-mono font-semibold">{user.badge_code}</span> Badge: <span className="font-mono font-semibold">{user.badge_code}</span>
</p> </p>
</div> </div>
</div> </div>
{/* Warning Box */} {/* Warning Box */}
{user.warning && ( {user.warning && (
<div className="mt-4 rounded-xl bg-error/20 border-2 border-error p-4"> <div className="mt-4 rounded-xl bg-error/20 border-2 border-error p-4">
<p className="text-lg font-bold text-error text-center"> <p className="text-lg font-bold text-error text-center">
{user.warning} {user.warning}
</p> </p>
</div>
)}
</div> </div>
)} );
</div>
);
} }
export default UserCard; export default UserCard;

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

@@ -1,8 +1,10 @@
// Components barrel export // Components barrel export
export { Logo } from './Logo'; export {Logo} from './Logo';
export { UserCard } from './UserCard'; export {UserCard} from './UserCard';
export { CountdownTimer } from './CountdownTimer'; export {CountdownTimer} from './CountdownTimer';
export { Modal } from './Modal'; export {Modal} from './Modal';
export { RFIDStatus } from './RFIDStatus'; export {RFIDStatus} from './RFIDStatus';
export { Button } from './Button'; export {Button} from './Button';
export { Input } from './Input'; export {Input} from './Input';
export {WelcomeCarousel} from './WelcomeCarousel';
export {NumLockBanner} from './NumLockBanner';

View File

@@ -1,219 +1,335 @@
/** /**
* Focolari Voting System - RFID Scanner Hook * Focolari Voting System - RFID Scanner Hook (v2 - Multi-Pattern)
* *
* Questo hook gestisce la lettura di badge RFID tramite lettori USB * Questo hook gestisce la lettura di badge RFID tramite lettori USB
* che emulano una tastiera. Il protocollo prevede: * che emulano una tastiera. Supporta pattern multipli per diversi layout:
* - Carattere di inizio: `;` * - Layout US: `;` → `?`
* - Carattere di fine: `?` * - Layout IT: `ò` → `_`
* - Esempio: `;00012345?` *
* 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. * 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'; import type {RFIDScannerState, RFIDScanResult} from '../types';
// Costanti // ============================================
const START_SENTINEL = ';'; // CONFIGURAZIONE PATTERN RFID
const END_SENTINEL = '?'; // ============================================
const TIMEOUT_MS = 3000; // 3 secondi di timeout
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 { interface UseRFIDScannerOptions {
/** Callback chiamato quando un badge viene letto con successo */ /** Callback chiamato quando un badge viene letto con successo */
onScan: (code: string) => void; onScan: (code: string) => void;
/** Callback opzionale chiamato in caso di timeout */ /** Callback opzionale chiamato in caso di timeout */
onTimeout?: () => void; onTimeout?: () => void;
/** Callback opzionale chiamato quando inizia la scansione */ /** Callback opzionale chiamato quando inizia la scansione */
onScanStart?: () => void; onScanStart?: () => void;
/** Se true, previene l'input nei campi di testo durante la scansione */ /** Se true, previene l'input nei campi di testo durante la scansione */
preventDefaultOnScan?: boolean; preventDefaultOnScan?: boolean;
/** Se true, l'hook è disabilitato */ /** Se true, l'hook è disabilitato */
disabled?: boolean; disabled?: boolean;
} }
interface UseRFIDScannerReturn { export interface UseRFIDScannerReturn {
/** Stato corrente dello scanner */ /** Stato corrente dello scanner */
state: RFIDScannerState; state: RFIDScannerState;
/** Buffer corrente (solo per debug) */ /** Buffer corrente (solo per debug) */
buffer: string; buffer: string;
/** Ultimo codice scansionato */ /** Ultimo codice scansionato */
lastScan: RFIDScanResult | null; lastScan: RFIDScanResult | null;
/** Reset manuale dello scanner */ /** Pattern attualmente in uso (solo durante scanning) */
reset: () => void; 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({ export function useRFIDScanner({
onScan, onScan,
onTimeout, onTimeout,
onScanStart, onScanStart,
preventDefaultOnScan = true, preventDefaultOnScan = true,
disabled = false, disabled = false,
}: UseRFIDScannerOptions): UseRFIDScannerReturn { }: UseRFIDScannerOptions): UseRFIDScannerReturn {
const [state, setState] = useState<RFIDScannerState>('idle'); const [state, setState] = useState<RFIDScannerState>('idle');
const [buffer, setBuffer] = useState<string>(''); const [buffer, setBuffer] = useState<string>('');
const [lastScan, setLastScan] = useState<RFIDScanResult | null>(null); 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 // Refs per mantenere i valori aggiornati nei callback
const bufferRef = useRef<string>(''); const bufferRef = useRef<string>('');
const stateRef = useRef<RFIDScannerState>('idle'); const stateRef = useRef<RFIDScannerState>('idle');
const timeoutRef = useRef<number | null>(null); const activePatternRef = useRef<RFIDPattern | null>(null);
const timeoutRef = useRef<number | null>(null);
const lastCompletionRef = useRef<number>(0);
// Sync refs con state // Sync refs con state
useEffect(() => { useEffect(() => {
bufferRef.current = buffer; bufferRef.current = buffer;
}, [buffer]); }, [buffer]);
useEffect(() => { useEffect(() => {
stateRef.current = state; stateRef.current = state;
}, [state]); }, [state]);
/** useEffect(() => {
* Pulisce il timeout attivo activePatternRef.current = activePattern;
*/ }, [activePattern]);
const clearScanTimeout = useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
/** /**
* Resetta lo scanner allo stato idle * Aggiunge un evento al log tastiera
*/ */
const reset = useCallback(() => { const addKeyLog = useCallback((key: string, code: string) => {
clearScanTimeout(); setKeyLog(prev => {
setState('idle'); const newEntry: KeyLogEntry = {key, code, timestamp: Date.now()};
setBuffer(''); const updated = [newEntry, ...prev].slice(0, MAX_KEY_LOG);
bufferRef.current = ''; return updated;
stateRef.current = 'idle'; });
}, [clearScanTimeout]); }, []);
/** /**
* Avvia il timeout di sicurezza * Pulisce il timeout attivo
*/ */
const startTimeout = useCallback(() => { const clearScanTimeout = useCallback(() => {
clearScanTimeout(); if (timeoutRef.current !== null) {
timeoutRef.current = window.setTimeout(() => { window.clearTimeout(timeoutRef.current);
console.warn('[RFID Scanner] Timeout - lettura incompleta scartata'); timeoutRef.current = null;
onTimeout?.();
reset();
}, TIMEOUT_MS);
}, [clearScanTimeout, onTimeout, reset]);
/**
* Handler principale per gli eventi keydown
*/
useEffect(() => {
if (disabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key;
// Ignora tasti speciali (frecce, funzione, ecc.)
if (key.length > 1 && key !== START_SENTINEL && key !== END_SENTINEL) {
// Eccezione per Backspace in stato scanning: ignora ma non resetta
if (key === 'Backspace' && stateRef.current === 'scanning') {
if (preventDefaultOnScan) {
event.preventDefault();
}
} }
return; }, []);
}
// STATO IDLE: attende il carattere di inizio /**
if (stateRef.current === 'idle') { * Resetta lo scanner allo stato idle
if (key === START_SENTINEL) { */
console.log('[RFID Scanner] Start sentinel rilevato - inizio scansione'); const reset = useCallback(() => {
clearScanTimeout();
setState('idle');
setBuffer('');
setActivePattern(null);
bufferRef.current = '';
stateRef.current = 'idle';
activePatternRef.current = null;
}, [clearScanTimeout]);
if (preventDefaultOnScan) { /**
event.preventDefault(); * Avvia il timeout di sicurezza
} */
const startTimeout = useCallback(() => {
clearScanTimeout();
timeoutRef.current = window.setTimeout(() => {
logWarn('Buffer timeout - clearing data');
onTimeout?.();
reset();
}, TIMEOUT_MS);
}, [clearScanTimeout, onTimeout, reset]);
setState('scanning'); /**
setBuffer(''); * Trova il pattern che corrisponde al carattere start
bufferRef.current = ''; */
startTimeout(); const findPatternByStart = useCallback((char: string): RFIDPattern | undefined => {
onScanStart?.(); return VALID_PATTERNS.find(p => p.start === char);
} }, []);
// Altrimenti ignora il tasto (comportamento normale)
return;
}
// STATO SCANNING: accumula i caratteri o termina /**
if (stateRef.current === 'scanning') { * Handler principale per gli eventi keydown
if (preventDefaultOnScan) { */
event.preventDefault(); useEffect(() => {
if (disabled) {
return;
} }
if (key === END_SENTINEL) { const handleKeyDown = (event: KeyboardEvent) => {
// Fine della scansione const key = event.key;
clearScanTimeout(); const code = event.code;
const scannedCode = bufferRef.current.trim(); // Log per debug
addKeyLog(key, code);
if (scannedCode.length > 0) { // Gestione Enter dopo completamento (grace period)
console.log('[RFID Scanner] Codice scansionato:', scannedCode); if (key === 'Enter' && Date.now() - lastCompletionRef.current < ENTER_GRACE_PERIOD_MS) {
log('Enter ignorato (post-completion grace period)');
if (preventDefaultOnScan) {
event.preventDefault();
}
return;
}
const result: RFIDScanResult = { // Gestione ESC: annulla scansione in corso
code: scannedCode, if (key === 'Escape' && stateRef.current === 'scanning') {
timestamp: Date.now(), log('Scansione annullata con ESC');
}; if (preventDefaultOnScan) {
event.preventDefault();
}
reset();
return;
}
setLastScan(result); // Ignora tasti speciali (frecce, funzione, ecc.) ma non i sentinel
onScan(scannedCode); const isStartSentinel = VALID_PATTERNS.some(p => p.start === key);
} else { const isEndSentinel = activePatternRef.current?.end === key;
console.warn('[RFID Scanner] Codice vuoto scartato');
}
reset(); if (key.length > 1 && !isStartSentinel && !isEndSentinel) {
} else if (key === START_SENTINEL) { // Eccezione per Backspace/Enter in stato scanning: ignora ma non resetta
// Nuovo start sentinel durante scansione: resetta e ricomincia if ((key === 'Backspace' || key === 'Enter') && stateRef.current === 'scanning') {
console.log('[RFID Scanner] Nuovo start sentinel - reset buffer'); if (preventDefaultOnScan) {
setBuffer(''); event.preventDefault();
bufferRef.current = ''; }
startTimeout(); }
} else { return;
// Accumula il carattere nel buffer }
const newBuffer = bufferRef.current + key;
setBuffer(newBuffer); // STATO IDLE: attende un carattere start di qualsiasi pattern
bufferRef.current = newBuffer; if (stateRef.current === 'idle') {
} const pattern = findPatternByStart(key);
}
if (pattern) {
log(`Start sentinel detected: '${key}' (pattern ${pattern.name})`);
if (preventDefaultOnScan) {
event.preventDefault();
}
setState('scanning');
setActivePattern(pattern);
setBuffer('');
bufferRef.current = '';
activePatternRef.current = pattern;
startTimeout();
onScanStart?.();
}
// Altrimenti ignora il tasto (comportamento normale)
return;
}
// STATO SCANNING: accumula i caratteri o termina
if (stateRef.current === 'scanning') {
if (preventDefaultOnScan) {
event.preventDefault();
}
const currentPattern = activePatternRef.current;
// Verifica se è l'end sentinel del pattern attivo
if (currentPattern && key === currentPattern.end) {
// Fine della scansione
clearScanTimeout();
lastCompletionRef.current = Date.now();
const scannedCode = bufferRef.current.trim();
if (scannedCode.length > 0) {
log(`Scan complete (pattern ${currentPattern.name}): ${scannedCode}`);
const result: RFIDScanResult = {
code: scannedCode,
timestamp: Date.now(),
};
setLastScan(result);
onScan(scannedCode);
} else {
logWarn('Empty code discarded');
}
reset();
} else if (findPatternByStart(key)) {
// Nuovo start sentinel durante scansione: resetta e ricomincia con nuovo pattern
const newPattern = findPatternByStart(key)!;
log(`New start sentinel during scan - switching to pattern ${newPattern.name}`);
setBuffer('');
bufferRef.current = '';
setActivePattern(newPattern);
activePatternRef.current = newPattern;
startTimeout();
} else {
// Accumula il carattere nel buffer
const newBuffer = bufferRef.current + key;
setBuffer(newBuffer);
bufferRef.current = newBuffer;
}
}
};
// Aggiungi listener globale
window.addEventListener('keydown', handleKeyDown, {capture: true});
// Cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown, {capture: true});
clearScanTimeout();
};
}, [
disabled,
onScan,
onScanStart,
preventDefaultOnScan,
clearScanTimeout,
reset,
startTimeout,
findPatternByStart,
addKeyLog,
]);
// Cleanup al unmount
useEffect(() => {
return () => {
clearScanTimeout();
};
}, [clearScanTimeout]);
return {
state,
buffer,
lastScan,
activePattern,
reset,
keyLog,
}; };
// Aggiungi listener globale
window.addEventListener('keydown', handleKeyDown, { capture: true });
// Cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
clearScanTimeout();
};
}, [
disabled,
onScan,
onScanStart,
preventDefaultOnScan,
clearScanTimeout,
reset,
startTimeout,
]);
// Cleanup al unmount
useEffect(() => {
return () => {
clearScanTimeout();
};
}, [clearScanTimeout]);
return {
state,
buffer,
lastScan,
reset,
};
} }
export default useRFIDScanner; export default useRFIDScanner;

View File

@@ -5,25 +5,25 @@
============================================ */ ============================================ */
@theme { @theme {
/* Colori Istituzionali */ /* Colori Istituzionali */
--color-focolare-blue: #0072CE; --color-focolare-blue: #0072CE;
--color-focolare-blue-dark: #005BA1; --color-focolare-blue-dark: #005BA1;
--color-focolare-blue-light: #3D9BE0; --color-focolare-blue-light: #3D9BE0;
--color-focolare-orange: #F5A623; --color-focolare-orange: #F5A623;
--color-focolare-orange-dark: #D4891C; --color-focolare-orange-dark: #D4891C;
--color-focolare-orange-light: #FFB84D; --color-focolare-orange-light: #FFB84D;
--color-focolare-yellow: #FFD700; --color-focolare-yellow: #FFD700;
--color-focolare-yellow-dark: #CCA300; --color-focolare-yellow-dark: #CCA300;
--color-focolare-yellow-light: #FFE44D; --color-focolare-yellow-light: #FFE44D;
/* Stati */ /* Stati */
--color-success: #22C55E; --color-success: #22C55E;
--color-success-dark: #16A34A; --color-success-dark: #16A34A;
--color-error: #EF4444; --color-error: #EF4444;
--color-error-dark: #DC2626; --color-error-dark: #DC2626;
--color-warning: #F59E0B; --color-warning: #F59E0B;
} }
/* ============================================ /* ============================================
@@ -31,25 +31,25 @@
============================================ */ ============================================ */
l:root { l:root {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light; color-scheme: light;
} }
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* ============================================ /* ============================================
@@ -58,14 +58,14 @@ html, body {
button, button,
.touch-target { .touch-target {
min-height: 48px; min-height: 48px;
min-width: 48px; min-width: 48px;
touch-action: manipulation; touch-action: manipulation;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
input { input {
font-size: 16px; /* Previene zoom su iOS */ font-size: 16px; /* Previene zoom su iOS */
} }
/* ============================================ /* ============================================
@@ -73,70 +73,70 @@ input {
============================================ */ ============================================ */
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 100% { 0%, 100% {
box-shadow: 0 0 20px rgba(34, 197, 94, 0.5); box-shadow: 0 0 20px rgba(34, 197, 94, 0.5);
} }
50% { 50% {
box-shadow: 0 0 40px rgba(34, 197, 94, 0.8); box-shadow: 0 0 40px rgba(34, 197, 94, 0.8);
} }
} }
@keyframes pulse-error { @keyframes pulse-error {
0%, 100% { 0%, 100% {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5); box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
} }
50% { 50% {
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8); box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
} }
} }
@keyframes blink { @keyframes blink {
0%, 50%, 100% { 0%, 50%, 100% {
opacity: 1; opacity: 1;
} }
25%, 75% { 25%, 75% {
opacity: 0.4; opacity: 0.4;
} }
} }
@keyframes slide-up { @keyframes slide-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
} }
.animate-pulse-glow { .animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite; animation: pulse-glow 2s ease-in-out infinite;
} }
.animate-pulse-error { .animate-pulse-error {
animation: pulse-error 1s ease-in-out infinite; animation: pulse-error 1s ease-in-out infinite;
} }
.animate-blink { .animate-blink {
animation: blink 1.5s step-start infinite; animation: blink 1.5s step-start infinite;
} }
.animate-slide-up { .animate-slide-up {
animation: slide-up 0.3s ease-out; animation: slide-up 0.3s ease-out;
} }
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.3s ease-out; animation: fade-in 0.3s ease-out;
} }
/* ============================================ /* ============================================
@@ -144,11 +144,11 @@ input {
============================================ */ ============================================ */
.text-shadow { .text-shadow {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.glass { .glass {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }

View File

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

View File

@@ -3,211 +3,293 @@
* Schermata principale del varco attivo * Schermata principale del varco attivo
*/ */
import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components'; import {useEffect, useState} from 'react';
import type { RoomInfo, User, RFIDScannerState } from '../types'; 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 { interface ActiveGateScreenProps {
roomInfo: RoomInfo; roomInfo: RoomInfo;
rfidState: RFIDScannerState; rfidState: RFIDScannerState;
rfidBuffer: string; rfidBuffer: string;
currentUser: User | null; currentUser: User | null;
loading: boolean; notFoundBadge: string | null;
error?: string; loading: boolean;
onCancelUser: () => void; error?: string;
onLogout: () => void; onCancelUser: () => void;
userTimeoutSeconds?: number; onLogout: () => void;
onUserTimeout: () => void; userTimeoutSeconds?: number;
onUserTimeout: () => void;
showValidatorBadgeNotice?: boolean;
} }
export function ActiveGateScreen({ export function ActiveGateScreen({
roomInfo, roomInfo,
rfidState, rfidState,
rfidBuffer, rfidBuffer,
currentUser, currentUser,
loading, notFoundBadge,
error, loading,
onCancelUser, error,
onLogout, onCancelUser,
userTimeoutSeconds = 60, onLogout,
onUserTimeout, userTimeoutSeconds = 60,
}: ActiveGateScreenProps) { onUserTimeout,
return ( showValidatorBadgeNotice = false,
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100"> }: ActiveGateScreenProps) {
{/* Header */} // Timer per countdown badge non trovato
<header className="p-4 md:p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm"> const [notFoundCountdown, setNotFoundCountdown] = useState(NOT_FOUND_TIMEOUT_SECONDS);
<Logo size="md" />
<div className="flex items-center gap-4 md:gap-8">
<div className="text-right hidden sm:block">
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={onLogout}
>
Esci
</Button>
</div>
</header>
{/* Main Content */} // Reset countdown quando cambia notFoundBadge
<main className="flex-1 flex items-center justify-center p-4 md:p-8"> useEffect(() => {
{loading ? ( if (notFoundBadge) {
// Loading state setNotFoundCountdown(NOT_FOUND_TIMEOUT_SECONDS);
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center"> const interval = setInterval(() => {
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6"> setNotFoundCountdown(prev => {
<svg if (prev <= 1) {
className="animate-spin h-10 w-10 text-focolare-blue" clearInterval(interval);
viewBox="0 0 24 24" onUserTimeout();
> return 0;
<circle }
className="opacity-25" return prev - 1;
cx="12" });
cy="12" }, 1000);
r="10" return () => clearInterval(interval);
stroke="currentColor" }
strokeWidth="4" }, [notFoundBadge, onUserTimeout]);
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
<p className="text-xl text-gray-600">Caricamento dati...</p>
</div>
) : error ? (
// Error state
<div className="glass rounded-3xl p-12 shadow-xl animate-slide-up text-center max-w-md">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
<svg
className="h-10 w-10 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-xl text-error font-semibold mb-4">{error}</p>
<Button variant="secondary" onClick={onCancelUser}>
Chiudi
</Button>
</div>
) : currentUser ? (
// User found - Decision screen
<div className="w-full max-w-4xl animate-slide-up">
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
{/* Timer bar */}
<div className="mb-6">
<CountdownTimer
seconds={userTimeoutSeconds}
onExpire={onUserTimeout}
warningThreshold={20}
dangerThreshold={10}
/>
</div>
{/* User Card */} return (
<UserCard user={currentUser} size="full" /> <div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
{/* Header */}
<header
className="p-4 md:p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
<Logo size="md"/>
<div className="flex items-center gap-4 md:gap-8">
<div className="text-right hidden sm:block">
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={onLogout}
>
Esci
</Button>
</div>
</header>
{/* Action Hint */} {/* Notifica Badge Validatore Ignorato */}
<div className="mt-8 text-center"> {showValidatorBadgeNotice && (
{currentUser.ammesso ? ( <div className="absolute top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in">
<div className="py-6 px-8 bg-success/10 rounded-2xl border-2 border-success/30"> <div className="bg-amber-100 border border-amber-400 text-amber-800 px-6 py-3 rounded-xl shadow-lg">
<p className="text-xl text-success font-semibold mb-2"> <p className="font-semibold">Badge validatore rilevato</p>
Utente ammesso all'ingresso <p className="text-sm">Se il validatore è cambiato, esci e rilogga con il nuovo badge.</p>
</p> </div>
<p className="text-lg text-gray-600"> </div>
Passa il <span className="font-bold text-focolare-blue">badge VALIDATORE</span> per confermare l'accesso )}
</p>
</div> {/* Main Content */}
<main className="flex-1 flex items-center justify-center p-4 md:p-8">
{loading ? (
// Loading state
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center">
<div
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6">
<svg
className="animate-spin h-10 w-10 text-focolare-blue"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
<p className="text-xl text-gray-600">Caricamento dati...</p>
</div>
) : notFoundBadge ? (
// Badge non trovato
<div className="glass rounded-3xl p-12 md:p-16 shadow-xl text-center max-w-2xl w-full">
<div
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-error/10 mb-8">
<svg
className="h-16 w-16 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-2xl text-gray-700 mb-2">Utente con badge:</p>
<p className="text-3xl font-bold text-error font-mono mb-4">{notFoundBadge}</p>
<p className="text-2xl text-gray-700">non trovato nel sistema</p>
{/* Footer con countdown */}
<div className="mt-10 pt-6 border-t border-gray-200">
<p className="text-gray-500">
Ritorno all'attesa in <span
className="font-bold text-focolare-blue">{notFoundCountdown}</span> secondi
</p>
</div>
</div>
) : error ? (
// Error state
<div className="glass rounded-3xl p-12 shadow-xl text-center max-w-md">
<div
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
<svg
className="h-10 w-10 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-xl text-error font-semibold mb-4">{error}</p>
<Button variant="secondary" onClick={onCancelUser}>
Chiudi
</Button>
</div>
) : currentUser ? (
// User found - Decision screen
<div className="w-full max-w-5xl animate-slide-up">
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
{/* Timer bar */}
<div className="mb-6">
<CountdownTimer
seconds={userTimeoutSeconds}
onExpire={onUserTimeout}
warningThreshold={20}
dangerThreshold={10}
/>
</div>
{/* User Card */}
<UserCard user={currentUser} size="full"/>
{/* Action Hint */}
<div className="mt-8 text-center">
{currentUser.ammesso ? (
<div className="py-6 px-8 bg-success/10 rounded-2xl border-2 border-success/30">
<p className="text-xl text-success font-semibold mb-2">
✓ Utente ammesso all'ingresso
</p>
<p className="text-lg text-gray-600">
Passa il <span
className="font-bold text-focolare-blue">badge VALIDATORE</span> per
confermare l'accesso
</p>
</div>
) : (
<div className="py-6 px-8 bg-error/10 rounded-2xl border-2 border-error/30">
<p className="text-xl text-error font-bold mb-2 animate-blink">
⚠️ ACCESSO NON CONSENTITO
</p>
<p className="text-lg text-gray-600">
Questo utente non è autorizzato ad entrare
</p>
</div>
)}
</div>
{/* Cancel Button */}
<div className="mt-6 flex justify-center">
<Button
variant="secondary"
size="lg"
onClick={onCancelUser}
>
✕ Annulla
</Button>
</div>
</div>
</div>
) : ( ) : (
<div className="py-6 px-8 bg-error/10 rounded-2xl border-2 border-error/30"> // Idle - Waiting for participant
<p className="text-xl text-error font-bold mb-2 animate-blink"> <div
ACCESSO NON CONSENTITO className="glass rounded-3xl p-12 md:p-16 shadow-xl animate-fade-in text-center max-w-3xl w-full">
</p> <div
<p className="text-lg text-gray-600"> className="inline-flex items-center justify-center w-40 h-40 rounded-full bg-focolare-blue/10 mb-10">
Questo utente non è autorizzato ad entrare <svg
</p> className="w-20 h-20 text-focolare-blue"
</div> fill="none"
)} viewBox="0 0 24 24"
</div> stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
{/* Cancel Button */} <h1 className="text-5xl font-bold text-focolare-blue mb-6">
<div className="mt-6 flex justify-center"> Varco Attivo
<Button </h1>
variant="secondary"
size="lg"
onClick={onCancelUser}
>
Annulla
</Button>
</div>
</div>
</div>
) : (
// Idle - Waiting for participant
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center max-w-xl">
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-focolare-blue/10 mb-8">
<svg
className="w-16 h-16 text-focolare-blue"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h1 className="text-4xl font-bold text-focolare-blue mb-4"> <p className="text-3xl text-gray-600 mb-10">
Varco Attivo In attesa del partecipante...
</h1> </p>
<p className="text-2xl text-gray-600 mb-8"> <div
In attesa del partecipante... className="py-10 px-8 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40">
</p> <div className="flex items-center justify-center gap-4">
<svg
<div className="py-8 px-6 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40"> className="w-12 h-12 text-focolare-orange animate-pulse"
<div className="flex items-center justify-center gap-4"> fill="currentColor"
<svg viewBox="0 0 24 24"
className="w-10 h-10 text-focolare-orange animate-pulse" >
fill="currentColor" <path
viewBox="0 0 24 24" 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>
<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"/> <span className="text-3xl text-focolare-orange font-medium">
</svg>
<span className="text-2xl text-focolare-orange font-medium">
Passa il badge Passa il badge
</span> </span>
</div> </div>
</div> </div>
</div>
)}
</main>
{/* Footer with RFID Status */} {/* Banner NumLock per desktop */}
<footer className="p-4 border-t bg-white/50 flex items-center justify-between"> <NumLockBanner className="mt-8"/>
<RFIDStatus state={rfidState} buffer={rfidBuffer} /> </div>
<span className="text-sm text-gray-400"> )}
</main>
{/* Footer with RFID Status */}
<footer className="p-4 border-t bg-white/50 flex items-center justify-between">
<RFIDStatus state={rfidState} buffer={rfidBuffer}/>
<span className="text-sm text-gray-400">
Varco attivo • {new Date().toLocaleTimeString('it-IT')} Varco attivo • {new Date().toLocaleTimeString('it-IT')}
</span> </span>
</footer> </footer>
</div> </div>
); );
} }
export default ActiveGateScreen; export default ActiveGateScreen;

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,70 +3,71 @@
* Modal per errori * Modal per errori
*/ */
import { Modal, Button } from '../components'; import {Button, Modal} from '../components';
interface ErrorModalProps { interface ErrorModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: string; title?: string;
message: string; message: string;
} }
export function ErrorModal({ export function ErrorModal({
isOpen, isOpen,
onClose, onClose,
title = 'Errore', title = 'Errore',
message message
}: ErrorModalProps) { }: ErrorModalProps) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
variant="error" variant="error"
fullscreen fullscreen
>
<div className="text-center text-white p-8 max-w-2xl">
{/* Error Icon */}
<div className="mb-8">
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-error">
<svg
className="w-20 h-20 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-5xl md:text-6xl font-bold mb-6 animate-slide-up">
{title}
</h1>
{/* Error Message */}
<p className="text-2xl md:text-3xl opacity-90 mb-12 animate-fade-in">
{message}
</p>
{/* Close Button */}
<Button
variant="secondary"
size="lg"
onClick={onClose}
className="bg-white text-error hover:bg-gray-100"
> >
Chiudi <div className="text-center text-white p-8 max-w-2xl">
</Button> {/* Error Icon */}
</div> <div className="mb-8">
</Modal> <div
); className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-error">
<svg
className="w-20 h-20 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-5xl md:text-6xl font-bold mb-6 animate-slide-up">
{title}
</h1>
{/* Error Message */}
<p className="text-2xl md:text-3xl opacity-90 mb-12 animate-fade-in">
{message}
</p>
{/* Close Button */}
<Button
variant="secondary"
size="lg"
onClick={onClose}
className="bg-white text-error hover:bg-gray-100"
>
Chiudi
</Button>
</div>
</Modal>
);
} }
export default ErrorModal; export default ErrorModal;

View File

@@ -2,89 +2,153 @@
* Loading Screen - Focolari Voting System * Loading Screen - Focolari Voting System
*/ */
import { Logo } from '../components'; import {useCallback, useEffect, useState} from 'react';
import {Logo} from '../components';
import {checkServerHealth} from '../services/api';
interface LoadingScreenProps { interface LoadingScreenProps {
message?: string; message?: string;
error?: string; error?: string;
onRetry?: () => void; onRetry?: () => void;
} }
export function LoadingScreen({ export function LoadingScreen({
message = 'Connessione al server...', message = 'Connessione al server...',
error, error,
onRetry onRetry
}: LoadingScreenProps) { }: LoadingScreenProps) {
return ( const [isRetrying, setIsRetrying] = useState(false);
<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"> const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking');
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in max-w-lg w-full text-center">
<Logo size="lg" showText={false} />
<h1 className="mt-6 text-3xl font-bold text-focolare-blue"> // Ping automatico quando c'è un errore
Focolari Voting System useEffect(() => {
</h1> if (!error) return;
{!error ? ( const checkServer = async () => {
<> setServerStatus('checking');
<div className="mt-8"> const isOnline = await checkServerHealth();
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10"> setServerStatus(isOnline ? 'online' : 'offline');
<svg };
className="animate-spin h-8 w-8 text-focolare-blue"
viewBox="0 0 24 24" checkServer();
>
<circle // Ripeti il ping ogni 3 secondi
className="opacity-25" const interval = setInterval(checkServer, 3000);
cx="12" return () => clearInterval(interval);
cy="12" }, [error]);
r="10"
stroke="currentColor" const handleRetry = useCallback(async () => {
strokeWidth="4" if (!onRetry) return;
fill="none"
/> setIsRetrying(true);
<path const isOnline = await checkServerHealth();
className="opacity-75"
fill="currentColor" if (isOnline) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" onRetry();
/> } else {
</svg> setServerStatus('offline');
</div> }
setIsRetrying(false);
}, [onRetry]);
// Se il server torna online, riprova automaticamente
useEffect(() => {
if (serverStatus === 'online' && error && onRetry) {
console.log('[FLOW] Server tornato online, ricarico...');
onRetry();
}
}, [serverStatus, error, onRetry]);
return (
<div
className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-br from-focolare-blue/5 to-focolare-blue/20">
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in max-w-lg w-full text-center">
<Logo size="lg" showText={false}/>
<h1 className="mt-6 text-3xl font-bold text-focolare-blue">
Focolari Voting System
</h1>
{!error ? (
<>
<div className="mt-8">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10">
<svg
className="animate-spin h-8 w-8 text-focolare-blue"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
</div>
<p className="mt-4 text-gray-600 text-lg">{message}</p>
</>
) : (
<>
<div className="mt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/10">
<svg
className="h-8 w-8 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
{/* Server status indicator */}
<div className="mt-4 flex items-center justify-center gap-2">
<span className={`w-3 h-3 rounded-full ${
serverStatus === 'checking' ? 'bg-yellow-500 animate-pulse' :
serverStatus === 'online' ? 'bg-green-500' :
'bg-red-500'
}`}/>
<span className="text-sm text-gray-600">
{serverStatus === 'checking' && 'Verifica connessione...'}
{serverStatus === 'online' && 'Server raggiungibile - riconnessione...'}
{serverStatus === 'offline' && 'Server non raggiungibile'}
</span>
</div>
{onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
font-semibold hover:bg-focolare-blue/90 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRetrying ? 'Verifica in corso...' : 'Riprova'}
</button>
)}
</>
)}
</div> </div>
<p className="mt-4 text-gray-600 text-lg">{message}</p> </div>
</> );
) : (
<>
<div className="mt-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/10">
<svg
className="h-8 w-8 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
font-semibold hover:bg-focolare-blue-dark transition-colors"
>
Riprova
</button>
)}
</>
)}
</div>
</div>
);
} }
export default LoadingScreen; export default LoadingScreen;

View File

@@ -1,88 +1,78 @@
/** /**
* Success Modal - Focolari Voting System * 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 { interface SuccessModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
welcomeMessage: string; userName?: string;
userName?: string;
} }
export function SuccessModal({ export function SuccessModal({
isOpen, isOpen,
onClose, onClose,
welcomeMessage, userName
userName }: SuccessModalProps) {
}: SuccessModalProps) { return (
return ( <Modal
<Modal isOpen={isOpen}
isOpen={isOpen} onClose={onClose}
onClose={onClose} variant="success"
variant="success" autoCloseMs={5000}
autoCloseMs={5000} fullscreen
fullscreen >
> <div className="flex flex-col items-center justify-center min-h-screen p-8">
<div className="text-center text-white p-8"> {/* Success Icon */}
{/* Success Icon */} <div className="mb-8">
<div className="mb-8"> <div
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow"> className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow">
<svg <svg
className="w-20 h-20 text-white" className="w-20 h-20 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={3} strokeWidth={3}
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
</div> </div>
</div> </div>
{/* User Name */} {/* Carosello Messaggi Benvenuto */}
{userName && ( <WelcomeCarousel userName={userName}/>
<h2 className="text-4xl md:text-5xl font-bold mb-4 animate-slide-up">
{userName}
</h2>
)}
{/* Welcome Message */} {/* Sub text */}
<h1 className="text-5xl md:text-7xl font-bold mb-8 animate-slide-up"> <p className="text-2xl md:text-3xl text-white/80 mt-8 animate-fade-in">
{welcomeMessage} Ingresso registrato con successo
</h1> </p>
{/* Sub text */} {/* Auto-close indicator */}
<p className="text-2xl md:text-3xl opacity-80 animate-fade-in"> <div className="mt-12 w-full max-w-md">
Ingresso registrato con successo <div className="w-full h-2 bg-white/30 rounded-full overflow-hidden">
</p> <div
className="h-full bg-white rounded-full"
{/* Auto-close indicator */} style={{
<div className="mt-12"> animation: 'shrink 5s linear forwards',
<div className="w-64 h-2 bg-white/30 rounded-full mx-auto overflow-hidden"> }}
<div />
className="h-full bg-white rounded-full" </div>
style={{ <style>{`
animation: 'shrink 5s linear forwards',
}}
/>
</div>
<style>{`
@keyframes shrink { @keyframes shrink {
from { width: 100%; } from { width: 100%; }
to { width: 0%; } to { width: 0%; }
} }
`}</style> `}</style>
</div> </div>
</div> </div>
</Modal> </Modal>
); );
} }
export default SuccessModal; export default SuccessModal;

View File

@@ -2,178 +2,193 @@
* Validator Login Screen - Focolari Voting System * Validator Login Screen - Focolari Voting System
*/ */
import { useState, useRef, useEffect } from 'react'; import {useEffect, useRef, useState} from 'react';
import { Logo, Button, Input, RFIDStatus } from '../components'; import {Button, Input, Logo, NumLockBanner, RFIDStatus} from '../components';
import type { RoomInfo, RFIDScannerState } from '../types'; import type {RFIDScannerState, RoomInfo} from '../types';
interface ValidatorLoginScreenProps { interface ValidatorLoginScreenProps {
roomInfo: RoomInfo; roomInfo: RoomInfo;
rfidState: RFIDScannerState; rfidState: RFIDScannerState;
rfidBuffer: string; rfidBuffer: string;
validatorBadge: string | null; validatorBadge: string | null;
onPasswordSubmit: (password: string) => void; onPasswordSubmit: (password: string) => void;
onCancel: () => void; onCancel: () => void;
error?: string; error?: string;
loading?: boolean; loading?: boolean;
} }
export function ValidatorLoginScreen({ export function ValidatorLoginScreen({
roomInfo, roomInfo,
rfidState, rfidState,
rfidBuffer, rfidBuffer,
validatorBadge, validatorBadge,
onPasswordSubmit, onPasswordSubmit,
onCancel, onCancel,
error, error,
loading = false, loading = false,
}: ValidatorLoginScreenProps) { }: ValidatorLoginScreenProps) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Focus input quando appare il form password // Focus input quando appare il form password
useEffect(() => { useEffect(() => {
if (validatorBadge && inputRef.current) { if (validatorBadge && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
} }
}, [validatorBadge]); }, [validatorBadge]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password.trim()) { if (password.trim()) {
onPasswordSubmit(password); onPasswordSubmit(password);
} }
}; };
return ( return (
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100"> <div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
{/* Header */} {/* Header */}
<header className="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm"> <header className="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
<Logo size="md" /> <Logo size="md"/>
<div className="text-right"> <div className="text-right">
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p> <p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p> <p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex items-center justify-center p-8">
<div className="glass rounded-3xl p-10 shadow-xl max-w-xl w-full animate-slide-up">
{!validatorBadge ? (
// Attesa badge validatore
<>
<div className="text-center">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-focolare-blue/10 mb-6">
<svg
className="w-12 h-12 text-focolare-blue"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div> </div>
</header>
<h1 className="text-3xl font-bold text-gray-800 mb-4"> {/* Main Content */}
Accesso Varco <main className="flex-1 flex items-center justify-center p-8">
</h1> <div className="glass rounded-3xl p-10 shadow-xl max-w-xl w-full animate-slide-up">
{!validatorBadge ? (
// Attesa badge validatore
<>
<div className="text-center">
<div
className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-focolare-blue/10 mb-6">
<svg
className="w-12 h-12 text-focolare-blue"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<p className="text-xl text-gray-600 mb-8"> <h1 className="text-3xl font-bold text-gray-800 mb-4">
Passa il badge del <span className="font-semibold text-focolare-blue">Validatore</span> per iniziare Accesso Varco
</p> </h1>
<div className="py-8 px-6 bg-focolare-blue/5 rounded-2xl border-2 border-dashed border-focolare-blue/30"> <p className="text-xl text-gray-600 mb-8">
<div className="flex items-center justify-center gap-3"> Passa il badge del <span
<svg className="font-semibold text-focolare-blue">Validatore</span> per iniziare
className="w-8 h-8 text-focolare-blue animate-pulse" </p>
fill="currentColor"
viewBox="0 0 24 24" <div
> className="py-8 px-6 bg-focolare-blue/5 rounded-2xl border-2 border-dashed border-focolare-blue/30">
<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"/> <div className="flex items-center justify-center gap-3">
</svg> <svg
<span className="text-xl text-focolare-blue font-medium"> className="w-8 h-8 text-focolare-blue animate-pulse"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
</svg>
<span className="text-xl text-focolare-blue font-medium">
In attesa del badge... In attesa del badge...
</span> </span>
</div> </div>
</div> </div>
</div>
</>
) : (
// Form password
<>
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
<svg
className="w-10 h-10 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2"> {/* Banner NumLock per desktop */}
Badge Riconosciuto <NumLockBanner className="mt-6"/>
</h2>
<p className="text-gray-500">
Badge: <span className="font-mono font-semibold">{validatorBadge}</span>
</p>
</div>
<form onSubmit={handleSubmit}> {/* Messaggio errore */}
<Input {error && (
ref={inputRef} <div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
type="password" <p className="text-red-700">{error}</p>
label="Password Validatore" </div>
placeholder="Inserisci la password..." )}
value={password} </div>
onChange={(e) => setPassword(e.target.value)} </>
error={error} ) : (
autoComplete="off" // Form password
disabled={loading} <>
/> <div className="text-center mb-8">
<div
className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
<svg
className="w-10 h-10 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="mt-6 flex gap-4"> <h2 className="text-2xl font-bold text-gray-800 mb-2">
<Button Badge Riconosciuto
type="button" </h2>
variant="secondary" <p className="text-gray-500">
onClick={onCancel} Badge: <span className="font-mono font-semibold">{validatorBadge}</span>
disabled={loading} </p>
fullWidth </div>
>
Annulla <form onSubmit={handleSubmit}>
</Button> <Input
<Button ref={inputRef}
type="submit" type="password"
variant="primary" label="Password Validatore"
loading={loading} placeholder="Inserisci la password..."
fullWidth value={password}
> onChange={(e) => setPassword(e.target.value)}
Conferma error={error}
</Button> autoComplete="off"
disabled={loading}
/>
<div className="mt-6 flex gap-4">
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
fullWidth
>
Annulla
</Button>
<Button
type="submit"
variant="primary"
loading={loading}
fullWidth
>
Conferma
</Button>
</div>
</form>
</>
)}
</div> </div>
</form> </main>
</>
)} {/* Footer with RFID Status */}
<footer className="p-4 border-t bg-white/50 flex items-center justify-center">
<RFIDStatus state={rfidState} buffer={rfidBuffer}/>
</footer>
</div> </div>
</main> );
{/* Footer with RFID Status */}
<footer className="p-4 border-t bg-white/50 flex items-center justify-center">
<RFIDStatus state={rfidState} buffer={rfidBuffer} />
</footer>
</div>
);
} }
export default ValidatorLoginScreen; export default ValidatorLoginScreen;

View File

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

View File

@@ -2,67 +2,84 @@
* Focolari Voting System - API Service * Focolari Voting System - API Service
*/ */
import type { import type {EntryRequest, EntryResponse, LoginRequest, LoginResponse, RoomInfo, User} from '../types';
RoomInfo,
User,
LoginRequest,
LoginResponse,
EntryRequest,
EntryResponse
} 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 * Custom error class for API errors
*/ */
export class ApiError extends Error { export class ApiError extends Error {
constructor( public statusCode: number;
message: string, public detail?: string;
public statusCode: number,
public detail?: string constructor(
) { message: string,
super(message); statusCode: number,
this.name = 'ApiError'; detail?: string
} ) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
this.detail = detail;
}
} }
/** /**
* Generic fetch wrapper with error handling * Generic fetch wrapper with error handling
*/ */
async function apiFetch<T>( async function apiFetch<T>(
endpoint: string, endpoint: string,
options?: RequestInit options?: RequestInit
): Promise<T> { ): Promise<T> {
try { log(`Fetching ${options?.method || 'GET'} ${endpoint}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) { try {
const errorData = await response.json().catch(() => ({})); const response = await fetch(`${API_BASE_URL}${endpoint}`, {
throw new ApiError( ...options,
errorData.detail || `HTTP Error ${response.status}`, headers: {
response.status, 'Content-Type': 'application/json',
errorData.detail ...options?.headers,
); },
} });
return await response.json(); if (!response.ok) {
} catch (error) { const errorData = await response.json().catch(() => ({}));
if (error instanceof ApiError) { logError(`Error ${response.status}: ${errorData.detail || 'Unknown error'}`);
throw error; throw new ApiError(
errorData.detail || `HTTP Error ${response.status}`,
response.status,
errorData.detail
);
}
const data = await response.json();
log(`Response OK from ${endpoint}`);
return data;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
logError('Connection error:', error);
throw new ApiError(
'Errore di connessione al server',
0,
'Verifica che il server sia attivo'
);
} }
throw new ApiError(
'Errore di connessione al server',
0,
'Verifica che il server sia attivo'
);
}
} }
// ============================================ // ============================================
@@ -74,7 +91,7 @@ async function apiFetch<T>(
* Ottiene le informazioni sulla sala e la riunione * Ottiene le informazioni sulla sala e la riunione
*/ */
export async function getRoomInfo(): Promise<RoomInfo> { export async function getRoomInfo(): Promise<RoomInfo> {
return apiFetch<RoomInfo>('/info-room'); return apiFetch<RoomInfo>('/info-room');
} }
/** /**
@@ -82,14 +99,15 @@ export async function getRoomInfo(): Promise<RoomInfo> {
* Autentica il validatore con badge e password * Autentica il validatore con badge e password
*/ */
export async function loginValidator( export async function loginValidator(
badge: string, badge: string,
password: string password: string
): Promise<LoginResponse> { ): Promise<LoginResponse> {
const payload: LoginRequest = { badge, password }; log(`Login attempt for badge: ${badge}`);
return apiFetch<LoginResponse>('/login-validate', { const payload: LoginRequest = {badge, password};
method: 'POST', return apiFetch<LoginResponse>('/login-validate', {
body: JSON.stringify(payload), method: 'POST',
}); body: JSON.stringify(payload),
});
} }
/** /**
@@ -97,7 +115,8 @@ export async function loginValidator(
* Ottiene i dati anagrafici di un utente tramite badge * Ottiene i dati anagrafici di un utente tramite badge
*/ */
export async function getUserByBadge(badgeCode: string): Promise<User> { export async function getUserByBadge(badgeCode: string): Promise<User> {
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`); log(`Fetching anagrafica for badge: ${badgeCode}`);
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
} }
/** /**
@@ -105,17 +124,18 @@ export async function getUserByBadge(badgeCode: string): Promise<User> {
* Registra l'ingresso di un utente * Registra l'ingresso di un utente
*/ */
export async function requestEntry( export async function requestEntry(
userBadge: string, userBadge: string,
validatorPassword: string validatorPassword: string
): Promise<EntryResponse> { ): Promise<EntryResponse> {
const payload: EntryRequest = { log(`Entry request for badge: ${userBadge}`);
user_badge: userBadge, const payload: EntryRequest = {
validator_password: validatorPassword, user_badge: userBadge,
}; validator_password: validatorPassword,
return apiFetch<EntryResponse>('/entry-request', { };
method: 'POST', return apiFetch<EntryResponse>('/entry-request', {
body: JSON.stringify(payload), method: 'POST',
}); body: JSON.stringify(payload),
});
} }
// ============================================ // ============================================
@@ -126,10 +146,10 @@ export async function requestEntry(
* Check if the API server is reachable * Check if the API server is reachable
*/ */
export async function checkServerHealth(): Promise<boolean> { export async function checkServerHealth(): Promise<boolean> {
try { try {
const response = await fetch(`${API_BASE_URL}/`); const response = await fetch(`${API_BASE_URL}/`);
return response.ok; return response.ok;
} catch { } catch {
return false; return false;
} }
} }

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,40 @@
import { defineConfig } from 'vite' import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], 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,
},
},
},
}) })