feat: setup iniziale sistema controllo accessi Focolari
Struttura progetto: - Backend mock Python (FastAPI) con API per gestione varchi - Frontend React + TypeScript + Vite + Tailwind CSS - Documentazione e piani di sviluppo Backend (backend-mock/): - API REST: /info-room, /login-validate, /anagrafica, /entry-request - Dati mock: 7 utenti, validatore (999999/focolari) - CORS abilitato, docs OpenAPI automatiche - Configurazione pipenv per ambiente virtuale Frontend (frontend/): - State machine completa per flusso accesso varco - Hook useRFIDScanner per lettura badge (pattern singolo) - Componenti UI: Logo, Button, Input, Modal, UserCard, Timer - Schermate: Loading, Login, ActiveGate, Success/Error Modal - Design system con colori Focolari - Ottimizzato per tablet touch Documentazione (ai-prompts/): - Welcome guide per futuri agenti - Piano sviluppo backend e frontend con checklist DA COMPLETARE: - Hook RFID multi-pattern (US/IT/altri layout tastiera) - Pagina /debug per diagnostica in loco - Logging console strutturato
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ================================================
|
||||||
|
# Focolari Voting System - Root .gitignore
|
||||||
|
# ================================================
|
||||||
|
|
||||||
|
# IDE e Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS Generated Files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs generici
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# File ambiente
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Note personali (non committare)
|
||||||
|
NOTES.md
|
||||||
|
TODO.md
|
||||||
|
scratch/
|
||||||
|
ai-prompts/old_prompts
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 🗳️ Focolari Voting System
|
||||||
|
|
||||||
|
Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focolari**.
|
||||||
|
|
||||||
|
## 📖 Descrizione
|
||||||
|
|
||||||
|
Applicazione web ottimizzata per tablet che gestisce i varchi d'ingresso alle sale votazione. Il sistema utilizza lettori RFID USB (che emulano tastiera) per identificare validatori e partecipanti.
|
||||||
|
|
||||||
|
## 🏗️ Struttura Progetto
|
||||||
|
|
||||||
|
```
|
||||||
|
VotoFocolari/
|
||||||
|
├── ai-prompts/ # Documentazione sviluppo e prompt
|
||||||
|
├── backend-mock/ # API mock in Python FastAPI
|
||||||
|
└── frontend/ # App React + TypeScript + Tailwind
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend-mock
|
||||||
|
pipenv install
|
||||||
|
pipenv run start
|
||||||
|
# Server: http://localhost:8000
|
||||||
|
# Docs API: http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# App: http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Documentazione
|
||||||
|
|
||||||
|
Per dettagli tecnici, consulta la cartella `ai-prompts/`:
|
||||||
|
- `00-welcome-agent.md` - Panoramica progetto
|
||||||
|
- `01-backend-plan.md` - Piano sviluppo backend
|
||||||
|
- `02-frontend-plan.md` - Piano sviluppo frontend
|
||||||
|
|
||||||
|
## 🔐 Credenziali Test
|
||||||
|
|
||||||
|
- **Badge Validatore:** `999999`
|
||||||
|
- **Password:** `focolari`
|
||||||
|
|
||||||
|
## 📄 Licenza
|
||||||
|
|
||||||
|
Progetto privato - Movimento dei Focolari
|
||||||
206
ai-prompts/00-welcome-agent.md
Normal file
206
ai-prompts/00-welcome-agent.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# 🎯 Focolari Voting System - Guida Agente
|
||||||
|
|
||||||
|
## Panoramica Progetto
|
||||||
|
|
||||||
|
**Nome:** Sistema Controllo Accessi "Focolari Voting System"
|
||||||
|
**Committente:** Movimento dei Focolari
|
||||||
|
**Scopo:** Gestione dei varchi di accesso per le assemblee di voto del Movimento.
|
||||||
|
|
||||||
|
### Contesto d'Uso
|
||||||
|
|
||||||
|
Il sistema funziona su **tablet Android** (via browser Chrome) posizionati ai varchi d'ingresso delle sale votazione. Ogni tablet è collegato a un **lettore RFID USB** che emula tastiera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architettura
|
||||||
|
|
||||||
|
```
|
||||||
|
VotoFocolari/
|
||||||
|
├── ai-prompts/ # Documentazione e piani di sviluppo
|
||||||
|
├── backend-mock/ # Python FastAPI (server mock)
|
||||||
|
│ ├── main.py # Entry point con argparse
|
||||||
|
│ ├── Pipfile # Dipendenze pipenv
|
||||||
|
│ ├── api/ # Routes FastAPI
|
||||||
|
│ ├── schemas/ # Modelli Pydantic
|
||||||
|
│ └── data/ # Dataset JSON (default, test)
|
||||||
|
└── frontend/ # React + TypeScript + Vite + Tailwind
|
||||||
|
└── src/
|
||||||
|
├── App.tsx # State machine principale
|
||||||
|
├── hooks/ # Custom hooks (RFID scanner)
|
||||||
|
├── components/ # UI components
|
||||||
|
├── screens/ # Schermate complete
|
||||||
|
├── services/ # API layer
|
||||||
|
├── types/ # TypeScript definitions
|
||||||
|
└── tests/ # Test automatici use case
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Stack Tecnologico
|
||||||
|
|
||||||
|
### Backend Mock
|
||||||
|
- **Python 3.10+**
|
||||||
|
- **FastAPI** - Framework web asincrono
|
||||||
|
- **Uvicorn** - ASGI server
|
||||||
|
- **Pydantic** - Validazione dati
|
||||||
|
- **pipenv** - Gestione ambiente virtuale
|
||||||
|
- **argparse** - Parametri CLI (porta, dataset)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 19** - UI Library
|
||||||
|
- **TypeScript 5.9** - Type safety
|
||||||
|
- **Vite 7** - Build tool
|
||||||
|
- **Tailwind CSS 4** - Styling utility-first
|
||||||
|
- **React Router** - Routing (/, /debug)
|
||||||
|
- **Vitest** - Testing framework
|
||||||
|
- **Design:** Ottimizzato per touch tablet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Design System
|
||||||
|
|
||||||
|
| Colore | Hex | Uso |
|
||||||
|
|--------|-----|-----|
|
||||||
|
| Blu Istituzionale | `#0072CE` | Colore primario, brand |
|
||||||
|
| Arancio Accento | `#F5A623` | Azioni secondarie |
|
||||||
|
| Giallo | `#FFD700` | Evidenze |
|
||||||
|
| Verde Success | `#22C55E` | Conferme, ammesso |
|
||||||
|
| Rosso Error | `#EF4444` | Errori, non ammesso |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📟 Logica RFID (CRITICA)
|
||||||
|
|
||||||
|
Il lettore RFID simula una tastiera. **Non possiamo distinguerlo dalla digitazione umana** in base alla velocità.
|
||||||
|
|
||||||
|
### Protocollo
|
||||||
|
- **Formato:** `<start_sentinel><codice><end_sentinel>`
|
||||||
|
- **Esempio US:** `;00012345?`
|
||||||
|
- **Esempio IT:** `ò00012345_`
|
||||||
|
|
||||||
|
### Pattern Supportati
|
||||||
|
```typescript
|
||||||
|
const VALID_PATTERNS = [
|
||||||
|
{ start: ';', end: '?' }, // Layout US
|
||||||
|
{ start: 'ò', end: '_' }, // Layout IT
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strategia
|
||||||
|
1. Ascolto globale `keydown`
|
||||||
|
2. Se ricevo un carattere `start` → avvio buffer + timeout (2.5s)
|
||||||
|
3. Accumulo caratteri nel buffer
|
||||||
|
4. Se ricevo il corretto `end` → emetto codice pulito
|
||||||
|
5. Se timeout scade → scarto buffer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 State Machine Applicativa
|
||||||
|
|
||||||
|
```
|
||||||
|
LOADING → WAITING_VALIDATOR → [badge validatore]
|
||||||
|
→ VALIDATOR_PASSWORD → [password OK]
|
||||||
|
→ GATE_ACTIVE → [badge partecipante]
|
||||||
|
→ SHOWING_USER → [badge validatore]
|
||||||
|
→ SUCCESS_MODAL (5s, carosello multilingua) → GATE_ACTIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌍 Messaggi Benvenuto (Frontend)
|
||||||
|
|
||||||
|
Il backend risponde in modo **asettico** (solo `success: true`).
|
||||||
|
Il frontend mostra un **carosello automatico** di messaggi multilingua:
|
||||||
|
|
||||||
|
- Italiano, English, Français, Deutsch, Español, Português, 中文, 日本語
|
||||||
|
|
||||||
|
Scorrimento ogni ~2 secondi, durata modale 5 secondi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 File Chiave
|
||||||
|
|
||||||
|
| File | Descrizione |
|
||||||
|
|------|-------------|
|
||||||
|
| `backend-mock/main.py` | Entry point con argparse |
|
||||||
|
| `backend-mock/api/routes.py` | Definizione endpoint |
|
||||||
|
| `backend-mock/schemas/models.py` | Modelli Pydantic |
|
||||||
|
| `backend-mock/data/*.json` | Dataset utenti |
|
||||||
|
| `frontend/src/hooks/useRFIDScanner.ts` | Cuore del sistema - gestione lettore |
|
||||||
|
| `frontend/src/App.tsx` | State machine e orchestrazione |
|
||||||
|
| `frontend/src/components/WelcomeCarousel.tsx` | Carosello multilingua |
|
||||||
|
| `frontend/src/screens/DebugScreen.tsx` | Diagnostica RFID |
|
||||||
|
| `frontend/src/tests/` | Test automatici use case |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend-mock
|
||||||
|
pipenv install
|
||||||
|
pipenv run python main.py # Default
|
||||||
|
pipenv run python main.py -p 9000 # Porta custom
|
||||||
|
pipenv run python main.py -d data/test.json # Dataset test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Sviluppo
|
||||||
|
npm run test # Test suite
|
||||||
|
npm run test:ui # Test con UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Test Automatici
|
||||||
|
|
||||||
|
Suite di test per validazione use case:
|
||||||
|
|
||||||
|
| Test | Descrizione |
|
||||||
|
|------|-------------|
|
||||||
|
| UC01 | Login validatore |
|
||||||
|
| UC02 | Accesso partecipante ammesso |
|
||||||
|
| UC03 | Accesso negato |
|
||||||
|
| UC04 | Timeout sessione |
|
||||||
|
| UC05 | Cambio rapido badge |
|
||||||
|
| UC06 | Pattern RFID multipli |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debug
|
||||||
|
|
||||||
|
### Console Browser
|
||||||
|
Tutti i log sono prefissati:
|
||||||
|
- `[RFID]` - Eventi lettore badge
|
||||||
|
- `[FLOW]` - Transizioni stato
|
||||||
|
- `[API]` - Chiamate HTTP
|
||||||
|
|
||||||
|
### Pagina Debug (`/debug`)
|
||||||
|
Accesso: logo cliccabile 5 volte.
|
||||||
|
Mostra in tempo reale:
|
||||||
|
- Ultimi 20 tasti premuti
|
||||||
|
- Stato scanner (idle/scanning)
|
||||||
|
- Buffer corrente
|
||||||
|
- Pattern attivo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Note Importanti
|
||||||
|
|
||||||
|
1. **Badge Validatore:** `999999` con password `focolari`
|
||||||
|
2. **Sessione:** 30 minuti di timeout, salvata in localStorage
|
||||||
|
3. **Timeout utente:** 60 secondi sulla schermata decisione
|
||||||
|
4. **Multi-layout:** Il sistema supporta RFID su tastiera US e IT
|
||||||
|
5. **Backend asettico:** Nessun messaggio multilingua dal server
|
||||||
|
6. **Git:** Il progetto sfrutta un Repository per versionare e sviluppare, ma solo l'utente può eseguire comandi git, eccetto se richiesto diversamente, l'agente può solo chiedere di eseguire o suggerire cosa fare, ma mai prendere iniziative
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentazione Correlata
|
||||||
|
|
||||||
|
- `01-backend-plan.md` - Piano sviluppo backend
|
||||||
|
- `02-frontend-plan.md` - Piano sviluppo frontend
|
||||||
181
ai-prompts/01-backend-plan.md
Normal file
181
ai-prompts/01-backend-plan.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# 📋 Piano Sviluppo Backend Mock
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Server FastAPI mock per simulare il backend del sistema di controllo accessi.
|
||||||
|
Struttura modulare con separazione tra API, modelli e dati.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struttura Target
|
||||||
|
|
||||||
|
```
|
||||||
|
backend-mock/
|
||||||
|
├── main.py # Entry point con argparse
|
||||||
|
├── Pipfile # Dipendenze pipenv
|
||||||
|
├── requirements.txt # Backup dipendenze
|
||||||
|
├── .gitignore
|
||||||
|
├── api/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── routes.py # Definizione endpoint
|
||||||
|
├── schemas/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── models.py # Modelli Pydantic
|
||||||
|
└── data/
|
||||||
|
├── users_default.json # Dataset utenti default
|
||||||
|
└── users_test.json # Dataset per test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Sviluppo
|
||||||
|
|
||||||
|
### 1. Setup Progetto
|
||||||
|
|
||||||
|
- [x] Creare cartella `backend-mock/`
|
||||||
|
- [x] Creare `Pipfile` per pipenv
|
||||||
|
- [x] Configurare `.gitignore` per Python
|
||||||
|
- [ ] Creare struttura cartelle (`api/`, `schemas/`, `data/`)
|
||||||
|
|
||||||
|
### 2. Modelli Pydantic (`schemas/models.py`)
|
||||||
|
|
||||||
|
- [x] `LoginRequest` - badge + password
|
||||||
|
- [x] `EntryRequest` - user_badge + validator_password
|
||||||
|
- [x] `UserResponse` - dati utente + warning opzionale
|
||||||
|
- [x] `RoomInfoResponse` - nome sala + meeting_id
|
||||||
|
- [x] `LoginResponse` - success + message + token
|
||||||
|
- [x] `EntryResponse` - success + message (SENZA welcome_message)
|
||||||
|
- [ ] **DA FARE:** Spostare modelli in file dedicato
|
||||||
|
|
||||||
|
### 3. Dati Mock (`data/*.json`)
|
||||||
|
|
||||||
|
- [x] Costanti validatore (badge `999999`, password `focolari`)
|
||||||
|
- [x] Lista utenti mock (7 utenti con dati realistici)
|
||||||
|
- [x] Mix di ruoli: Votante, Tecnico, Ospite
|
||||||
|
- [x] Alcuni con `ammesso: false` per test
|
||||||
|
- [x] URL foto placeholder (randomuser.me)
|
||||||
|
- [ ] **DA FARE:** Estrarre dati in file JSON separato
|
||||||
|
- [ ] **DA FARE:** Creare dataset alternativo per test
|
||||||
|
- [ ] **DA FARE:** Caricare dati dinamicamente all'avvio
|
||||||
|
|
||||||
|
**Nota:** I messaggi di benvenuto multilingua sono stati **RIMOSSI** dal backend.
|
||||||
|
Il frontend gestirà autonomamente la visualizzazione internazionale con carosello.
|
||||||
|
|
||||||
|
### 4. Routes API (`api/routes.py`)
|
||||||
|
|
||||||
|
- [x] `GET /info-room` - info sala
|
||||||
|
- [x] `POST /login-validate` - autenticazione validatore
|
||||||
|
- [x] `GET /anagrafica/{badge_code}` - ricerca utente
|
||||||
|
- [x] Pulizia caratteri sentinel dal badge
|
||||||
|
- [x] Confronto con e senza zeri iniziali
|
||||||
|
- [x] Warning automatico se non ammesso
|
||||||
|
- [x] `POST /entry-request` - registrazione ingresso
|
||||||
|
- [x] Verifica password validatore
|
||||||
|
- [x] Verifica utente ammesso
|
||||||
|
- [x] Risposta asettica (solo success + message)
|
||||||
|
- [ ] **DA FARE:** Spostare routes in file dedicato
|
||||||
|
|
||||||
|
### 5. Entry Point (`main.py`)
|
||||||
|
|
||||||
|
- [x] Blocco `if __name__ == "__main__"`
|
||||||
|
- [x] Configurazione uvicorn (host, port)
|
||||||
|
- [x] Messaggi console all'avvio
|
||||||
|
- [ ] **DA FARE:** Implementare argparse con opzioni:
|
||||||
|
- `--port` / `-p` : porta server (default: 8000)
|
||||||
|
- `--data` / `-d` : path file JSON dati (default: `data/users_default.json`)
|
||||||
|
- `--host` : host binding (default: `0.0.0.0`)
|
||||||
|
- [ ] **DA FARE:** Caricamento dinamico dati da JSON
|
||||||
|
- [ ] **DA FARE:** Import routes da modulo `api`
|
||||||
|
|
||||||
|
### 6. Struttura Base FastAPI
|
||||||
|
|
||||||
|
- [x] Import FastAPI e dipendenze
|
||||||
|
- [x] Configurazione app con titolo e descrizione
|
||||||
|
- [x] Middleware CORS (allow all origins)
|
||||||
|
- [x] Endpoint root `/` per health check
|
||||||
|
- [ ] **DA FARE:** Refactor in struttura modulare
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema File JSON Dati
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"validator": {
|
||||||
|
"badge": "999999",
|
||||||
|
"password": "focolari"
|
||||||
|
},
|
||||||
|
"room": {
|
||||||
|
"room_name": "Sala Assemblea",
|
||||||
|
"meeting_id": "VOT-2024"
|
||||||
|
},
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"badge_code": "000001",
|
||||||
|
"nome": "Maria",
|
||||||
|
"cognome": "Rossi",
|
||||||
|
"url_foto": "https://...",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandi Esecuzione
|
||||||
|
|
||||||
|
### Avvio Standard
|
||||||
|
```bash
|
||||||
|
cd backend-mock
|
||||||
|
pipenv install
|
||||||
|
pipenv run python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avvio con Parametri Custom
|
||||||
|
```bash
|
||||||
|
# Porta diversa
|
||||||
|
pipenv run python main.py --port 9000
|
||||||
|
|
||||||
|
# Dataset test
|
||||||
|
pipenv run python main.py --data data/users_test.json
|
||||||
|
|
||||||
|
# Combinato
|
||||||
|
pipenv run python main.py -p 9000 -d data/users_test.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Rapidi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8000/
|
||||||
|
|
||||||
|
# Info sala
|
||||||
|
curl http://localhost:8000/info-room
|
||||||
|
|
||||||
|
# Ricerca utente
|
||||||
|
curl http://localhost:8000/anagrafica/000001
|
||||||
|
|
||||||
|
# Login validatore
|
||||||
|
curl -X POST http://localhost:8000/login-validate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"badge": "999999", "password": "focolari"}'
|
||||||
|
|
||||||
|
# Richiesta ingresso (risposta asettica)
|
||||||
|
curl -X POST http://localhost:8000/entry-request \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_badge": "000001", "validator_password": "focolari"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note Implementative
|
||||||
|
|
||||||
|
- Gli endpoint puliscono automaticamente i caratteri `;`, `?`, `ò`, `_` dai badge
|
||||||
|
- Il confronto badge avviene sia con che senza zeri iniziali
|
||||||
|
- Le risposte seguono lo standard HTTP (200 OK, 401 Unauthorized, 404 Not Found, 403 Forbidden)
|
||||||
|
- La documentazione OpenAPI è auto-generata su `/docs`
|
||||||
|
- **Risposta `/entry-request`:** JSON asettico `{ success: true, message: "..." }` senza messaggi multilingua
|
||||||
305
ai-prompts/02-frontend-plan.md
Normal file
305
ai-prompts/02-frontend-plan.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# 📋 Piano Sviluppo Frontend
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Applicazione React per tablet che gestisce il flusso di accesso ai varchi votazione, con lettura badge RFID.
|
||||||
|
Include sistema di test automatici per validazione e regression detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Sviluppo
|
||||||
|
|
||||||
|
### 1. Setup Progetto
|
||||||
|
|
||||||
|
- [x] Inizializzare Vite + React + TypeScript
|
||||||
|
- [x] Installare Tailwind CSS 4
|
||||||
|
- [x] Configurare `tsconfig.json`
|
||||||
|
- [x] Configurare `vite.config.ts`
|
||||||
|
- [x] Configurare `.gitignore`
|
||||||
|
|
||||||
|
### 2. Design System
|
||||||
|
|
||||||
|
- [x] Configurare colori custom in Tailwind
|
||||||
|
- [x] `focolare-blue: #0072CE`
|
||||||
|
- [x] `focolare-orange: #F5A623`
|
||||||
|
- [x] `focolare-yellow: #FFD700`
|
||||||
|
- [x] Stili globali in `index.css`
|
||||||
|
- [x] Animazioni custom (fade-in, slide-up, pulse-error, blink)
|
||||||
|
- [x] Classe `.glass` per effetto glassmorphism
|
||||||
|
|
||||||
|
### 3. Tipi TypeScript (`types/index.ts`)
|
||||||
|
|
||||||
|
- [x] `RoomInfo` - info sala
|
||||||
|
- [x] `User` - dati utente
|
||||||
|
- [x] `LoginRequest/Response`
|
||||||
|
- [x] `EntryRequest/Response` (SENZA welcome_message)
|
||||||
|
- [x] `AppState` - stati applicazione
|
||||||
|
- [x] `ValidatorSession` - sessione validatore
|
||||||
|
- [x] `RFIDScannerState` - stato scanner
|
||||||
|
- [x] `RFIDScanResult` - risultato scan
|
||||||
|
- [ ] **DA FARE:** Aggiornare `EntryResponse` rimuovendo `welcome_message`
|
||||||
|
|
||||||
|
### 4. API Service (`services/api.ts`)
|
||||||
|
|
||||||
|
- [x] Classe `ApiError` custom
|
||||||
|
- [x] Funzione `apiFetch` generica con error handling
|
||||||
|
- [x] `getRoomInfo()` - GET /info-room
|
||||||
|
- [x] `loginValidator()` - POST /login-validate
|
||||||
|
- [x] `getUserByBadge()` - GET /anagrafica/{badge}
|
||||||
|
- [x] `requestEntry()` - POST /entry-request
|
||||||
|
- [ ] **DA FARE:** Logging con prefisso `[API]`
|
||||||
|
|
||||||
|
### 5. Hook RFID Scanner (`hooks/useRFIDScanner.ts`)
|
||||||
|
|
||||||
|
#### Implementazione Base (v1)
|
||||||
|
|
||||||
|
- [x] Listener `keydown` globale
|
||||||
|
- [x] Stati: idle → scanning → idle
|
||||||
|
- [x] Singolo pattern (`;` / `?`)
|
||||||
|
- [x] Timeout sicurezza (3s)
|
||||||
|
- [x] `preventDefault` durante scan
|
||||||
|
- [x] Callback `onScan`, `onTimeout`, `onScanStart`
|
||||||
|
|
||||||
|
#### ⚠️ AGGIORNAMENTO RICHIESTO (v2) - Multi-Pattern
|
||||||
|
|
||||||
|
- [ ] Supporto pattern multipli (US, IT, altri)
|
||||||
|
```typescript
|
||||||
|
const VALID_PATTERNS = [
|
||||||
|
{ start: ';', end: '?' }, // Layout US
|
||||||
|
{ start: 'ò', end: '_' }, // Layout IT
|
||||||
|
];
|
||||||
|
```
|
||||||
|
- [ ] Rilevamento automatico pattern in uso
|
||||||
|
- [ ] Memorizzazione pattern attivo durante scan
|
||||||
|
- [ ] Validazione `end` solo per pattern corretto
|
||||||
|
- [ ] Logging avanzato con prefisso `[RFID]`
|
||||||
|
- [ ] Esportare info pattern attivo per debug page
|
||||||
|
|
||||||
|
### 6. Componenti UI (`components/`)
|
||||||
|
|
||||||
|
- [x] `Logo.tsx` - logo Focolari
|
||||||
|
- [x] `Button.tsx` - varianti primary/secondary/danger
|
||||||
|
- [x] `Input.tsx` - campo input styled
|
||||||
|
- [x] `Modal.tsx` - modale base
|
||||||
|
- [x] `RFIDStatus.tsx` - indicatore stato scanner
|
||||||
|
- [x] `UserCard.tsx` - card utente con foto e ruolo
|
||||||
|
- [x] `CountdownTimer.tsx` - timer con progress bar
|
||||||
|
- [x] `index.ts` - barrel export
|
||||||
|
- [ ] **DA FARE:** `WelcomeCarousel.tsx` - carosello messaggi multilingua
|
||||||
|
|
||||||
|
### 7. Schermate (`screens/`)
|
||||||
|
|
||||||
|
- [x] `LoadingScreen.tsx` - caricamento iniziale
|
||||||
|
- [x] `ValidatorLoginScreen.tsx` - attesa badge + password
|
||||||
|
- [x] `ActiveGateScreen.tsx` - varco attivo + scheda utente
|
||||||
|
- [x] `SuccessModal.tsx` - conferma ingresso fullscreen
|
||||||
|
- [x] `ErrorModal.tsx` - errore fullscreen
|
||||||
|
- [x] `index.ts` - barrel export
|
||||||
|
- [ ] **DA FARE:** `DebugScreen.tsx` - pagina diagnostica RFID
|
||||||
|
|
||||||
|
### 8. State Machine (`App.tsx`)
|
||||||
|
|
||||||
|
- [x] Stati applicazione gestiti
|
||||||
|
- [x] Integrazione `useRFIDScanner`
|
||||||
|
- [x] Gestione sessione validatore (localStorage)
|
||||||
|
- [x] Timeout sessione 30 minuti
|
||||||
|
- [x] Timeout utente 60 secondi
|
||||||
|
- [x] Cambio rapido badge partecipante
|
||||||
|
- [x] Conferma con badge validatore
|
||||||
|
- [ ] **DA FARE:** Logging transizioni con prefisso `[FLOW]`
|
||||||
|
|
||||||
|
### 9. Modale Successo - Carosello Internazionale
|
||||||
|
|
||||||
|
#### ⚠️ MODIFICA RICHIESTA
|
||||||
|
|
||||||
|
Il backend **NON** restituisce più messaggi multilingua.
|
||||||
|
Il frontend gestisce autonomamente la visualizzazione con un **carosello automatico**.
|
||||||
|
|
||||||
|
**Specifiche:**
|
||||||
|
- [ ] Creare componente `WelcomeCarousel.tsx`
|
||||||
|
- [ ] Lista messaggi di benvenuto in diverse lingue:
|
||||||
|
```typescript
|
||||||
|
const WELCOME_MESSAGES = [
|
||||||
|
{ lang: 'it', text: 'Benvenuto!' },
|
||||||
|
{ lang: 'en', text: 'Welcome!' },
|
||||||
|
{ lang: 'fr', text: 'Bienvenue!' },
|
||||||
|
{ lang: 'de', text: 'Willkommen!' },
|
||||||
|
{ lang: 'es', text: 'Bienvenido!' },
|
||||||
|
{ lang: 'pt', text: 'Bem-vindo!' },
|
||||||
|
{ lang: 'zh', text: '欢迎!' },
|
||||||
|
{ lang: 'ja', text: 'ようこそ!' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
- [ ] Scorrimento automatico (es. ogni 2 secondi)
|
||||||
|
- [ ] Animazione transizione fluida (fade o slide)
|
||||||
|
- [ ] Modale fullscreen verde con carosello al centro
|
||||||
|
- [ ] Durata totale modale: 5 secondi
|
||||||
|
|
||||||
|
### 10. Debug & Diagnostica
|
||||||
|
|
||||||
|
#### ⚠️ DA IMPLEMENTARE
|
||||||
|
|
||||||
|
- [ ] Pagina `/debug` dedicata
|
||||||
|
- [ ] Log ultimi 20 tasti premuti (key + code)
|
||||||
|
- [ ] Stato scanner real-time (idle/scanning)
|
||||||
|
- [ ] Buffer corrente
|
||||||
|
- [ ] Pattern attivo (US/IT/...)
|
||||||
|
- [ ] Ultimo codice rilevato
|
||||||
|
- [ ] Timestamp eventi
|
||||||
|
- [ ] Link/pulsante nascosto per accesso debug (es. logo cliccabile 5 volte)
|
||||||
|
- [ ] Logging console strutturato:
|
||||||
|
- [ ] `[RFID]` - eventi scanner
|
||||||
|
- [ ] `[FLOW]` - transizioni stato
|
||||||
|
- [ ] `[API]` - chiamate HTTP
|
||||||
|
|
||||||
|
### 11. Routing
|
||||||
|
|
||||||
|
- [ ] Installare React Router
|
||||||
|
- [ ] Route principale `/`
|
||||||
|
- [ ] Route debug `/debug`
|
||||||
|
|
||||||
|
### 12. Test Automatici (E2E / Use Case Validation)
|
||||||
|
|
||||||
|
#### ⚠️ NUOVA SEZIONE
|
||||||
|
|
||||||
|
Sistema di test per validazione formale dei flussi e regression detection.
|
||||||
|
|
||||||
|
**Struttura:**
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ └── tests/
|
||||||
|
│ ├── usecase/
|
||||||
|
│ │ ├── UC01_ValidatorLogin.test.ts
|
||||||
|
│ │ ├── UC02_ParticipantAccess.test.ts
|
||||||
|
│ │ ├── UC03_DeniedAccess.test.ts
|
||||||
|
│ │ ├── UC04_SessionTimeout.test.ts
|
||||||
|
│ │ ├── UC05_QuickBadgeSwitch.test.ts
|
||||||
|
│ │ └── UC06_RFIDMultiPattern.test.ts
|
||||||
|
│ └── helpers/
|
||||||
|
│ ├── mockRFID.ts # Simulatore eventi tastiera RFID
|
||||||
|
│ └── testUtils.ts # Utility comuni
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Case da Testare:**
|
||||||
|
|
||||||
|
- [ ] **UC01 - Login Validatore**
|
||||||
|
- Simula badge validatore (`;999999?`)
|
||||||
|
- Inserisce password corretta
|
||||||
|
- Verifica transizione a stato `gate-active`
|
||||||
|
- Verifica sessione salvata in localStorage
|
||||||
|
|
||||||
|
- [ ] **UC02 - Accesso Partecipante Ammesso**
|
||||||
|
- Da stato `gate-active`
|
||||||
|
- Simula badge partecipante ammesso
|
||||||
|
- Verifica caricamento dati utente
|
||||||
|
- Simula badge validatore per conferma
|
||||||
|
- Verifica modale successo con carosello
|
||||||
|
- Verifica ritorno a `gate-active`
|
||||||
|
|
||||||
|
- [ ] **UC03 - Accesso Negato**
|
||||||
|
- Simula badge partecipante NON ammesso
|
||||||
|
- Verifica visualizzazione warning rosso
|
||||||
|
- Verifica che conferma validatore sia bloccata
|
||||||
|
- Verifica pulsante annulla funzionante
|
||||||
|
|
||||||
|
- [ ] **UC04 - Timeout Sessione**
|
||||||
|
- Verifica scadenza sessione 30 minuti
|
||||||
|
- Verifica redirect a login validatore
|
||||||
|
- Verifica pulizia localStorage
|
||||||
|
|
||||||
|
- [ ] **UC05 - Cambio Rapido Badge**
|
||||||
|
- Con utente a schermo
|
||||||
|
- Simula nuovo badge partecipante
|
||||||
|
- Verifica sostituzione immediata dati
|
||||||
|
|
||||||
|
- [ ] **UC06 - Pattern RFID Multipli**
|
||||||
|
- Testa pattern US (`;` / `?`)
|
||||||
|
- Testa pattern IT (`ò` / `_`)
|
||||||
|
- Verifica stesso risultato finale
|
||||||
|
|
||||||
|
**Dipendenze Test:**
|
||||||
|
```bash
|
||||||
|
npm install -D vitest @testing-library/react @testing-library/user-event jsdom
|
||||||
|
```
|
||||||
|
|
||||||
|
**Script npm:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Correzioni Necessarie
|
||||||
|
|
||||||
|
### Hook RFID - Da Aggiornare
|
||||||
|
|
||||||
|
Il file `useRFIDScanner.ts` attualmente supporta **solo** il pattern US (`;` / `?`).
|
||||||
|
|
||||||
|
**Modifiche richieste:**
|
||||||
|
|
||||||
|
1. Aggiungere costante `VALID_PATTERNS` con pattern multipli
|
||||||
|
2. Modificare logica `handleKeyDown` per:
|
||||||
|
- Riconoscere qualsiasi `start` sentinel
|
||||||
|
- Memorizzare quale pattern è in uso
|
||||||
|
- Validare solo con l'`end` corrispondente
|
||||||
|
3. Aggiungere stato `activePattern` per debug
|
||||||
|
4. Migliorare logging
|
||||||
|
|
||||||
|
### Success Modal - Da Aggiornare
|
||||||
|
|
||||||
|
Attualmente usa `welcome_message` dal backend.
|
||||||
|
|
||||||
|
**Modifiche richieste:**
|
||||||
|
|
||||||
|
1. Rimuovere dipendenza da `welcome_message` API
|
||||||
|
2. Implementare `WelcomeCarousel` con messaggi hardcoded
|
||||||
|
3. Carosello auto-scroll ogni 2 secondi
|
||||||
|
4. Animazioni fluide tra messaggi
|
||||||
|
|
||||||
|
### Logging Console
|
||||||
|
|
||||||
|
Attualmente i log usano messaggi generici. Aggiornare tutti i `console.log` con prefissi standardizzati.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dipendenze da Aggiungere
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Routing
|
||||||
|
npm install react-router-dom
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitest/ui @vitest/coverage-v8
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandi Esecuzione
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Sviluppo
|
||||||
|
npm run build # Build produzione
|
||||||
|
npm run test # Test suite
|
||||||
|
npm run test:ui # Test con UI interattiva
|
||||||
|
npm run test:coverage # Coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note UI/UX
|
||||||
|
|
||||||
|
- Font grandi per leggibilità tablet
|
||||||
|
- Pulsanti touch-friendly (min 48px)
|
||||||
|
- Feedback visivo immediato su azioni
|
||||||
|
- Animazioni fluide ma non invasive
|
||||||
|
- Supporto landscape e portrait
|
||||||
|
- Carosello benvenuto: transizioni smooth, leggibilità massima
|
||||||
30
backend-mock/.gitignore
vendored
Normal file
30
backend-mock/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
18
backend-mock/Pipfile
Normal file
18
backend-mock/Pipfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
fastapi = ">=0.109.0"
|
||||||
|
uvicorn = {extras = ["standard"], version = ">=0.27.0"}
|
||||||
|
pydantic = ">=2.5.0"
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.10"
|
||||||
|
|
||||||
|
[scripts]
|
||||||
|
start = "python main.py"
|
||||||
|
dev = "uvicorn main:app --reload --host 0.0.0.0 --port 8000"
|
||||||
277
backend-mock/main.py
Normal file
277
backend-mock/main.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""
|
||||||
|
Focolari Voting System - Backend Mock
|
||||||
|
Sistema di controllo accessi per votazioni del Movimento dei Focolari
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Focolari Voting System API",
|
||||||
|
description="Backend mock per il sistema di controllo accessi",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS abilitato per tutti
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MODELLI PYDANTIC
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
badge: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class EntryRequest(BaseModel):
|
||||||
|
user_badge: str
|
||||||
|
validator_password: str
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
badge_code: str
|
||||||
|
nome: str
|
||||||
|
cognome: str
|
||||||
|
url_foto: str
|
||||||
|
ruolo: str
|
||||||
|
ammesso: bool
|
||||||
|
warning: Optional[str] = None
|
||||||
|
|
||||||
|
class RoomInfoResponse(BaseModel):
|
||||||
|
room_name: str
|
||||||
|
meeting_id: str
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
token: Optional[str] = None
|
||||||
|
|
||||||
|
class EntryResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
welcome_message: Optional[str] = None
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# DATI MOCK
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Credenziali Validatore
|
||||||
|
VALIDATOR_BADGE = "999999"
|
||||||
|
VALIDATOR_PASSWORD = "focolari"
|
||||||
|
MOCK_TOKEN = "focolare-validator-token-2024"
|
||||||
|
|
||||||
|
# Lista utenti mock
|
||||||
|
USERS_DB = [
|
||||||
|
{
|
||||||
|
"badge_code": "000001",
|
||||||
|
"nome": "Maria",
|
||||||
|
"cognome": "Rossi",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/women/1.jpg",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "000002",
|
||||||
|
"nome": "Giuseppe",
|
||||||
|
"cognome": "Bianchi",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/men/2.jpg",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "000003",
|
||||||
|
"nome": "Anna",
|
||||||
|
"cognome": "Verdi",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/women/3.jpg",
|
||||||
|
"ruolo": "Tecnico",
|
||||||
|
"ammesso": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "000004",
|
||||||
|
"nome": "Francesco",
|
||||||
|
"cognome": "Neri",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/men/4.jpg",
|
||||||
|
"ruolo": "Ospite",
|
||||||
|
"ammesso": False # Non ammesso!
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "000005",
|
||||||
|
"nome": "Lucia",
|
||||||
|
"cognome": "Gialli",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/women/5.jpg",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "000006",
|
||||||
|
"nome": "Paolo",
|
||||||
|
"cognome": "Blu",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/men/6.jpg",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": False # Non ammesso!
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"badge_code": "123456",
|
||||||
|
"nome": "Teresa",
|
||||||
|
"cognome": "Martini",
|
||||||
|
"url_foto": "https://randomuser.me/api/portraits/women/7.jpg",
|
||||||
|
"ruolo": "Votante",
|
||||||
|
"ammesso": True
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Messaggi di benvenuto multilingua
|
||||||
|
WELCOME_MESSAGES = [
|
||||||
|
"Benvenuto! / Welcome!",
|
||||||
|
"Bienvenue! / Willkommen!",
|
||||||
|
"Bienvenido! / Bem-vindo!",
|
||||||
|
"欢迎! / 歓迎!",
|
||||||
|
"Добро пожаловать! / مرحبا!"
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Endpoint di test per verificare che il server sia attivo"""
|
||||||
|
return {"status": "ok", "message": "Focolari Voting System API is running"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/info-room", response_model=RoomInfoResponse)
|
||||||
|
async def get_room_info():
|
||||||
|
"""
|
||||||
|
Restituisce le informazioni sulla sala e la riunione corrente.
|
||||||
|
"""
|
||||||
|
return RoomInfoResponse(
|
||||||
|
room_name="Sala Assemblea",
|
||||||
|
meeting_id="VOT-2024"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login-validate", response_model=LoginResponse)
|
||||||
|
async def login_validate(request: LoginRequest):
|
||||||
|
"""
|
||||||
|
Valida le credenziali del validatore.
|
||||||
|
Il badge deve essere quello del validatore (999999) e la password corretta.
|
||||||
|
"""
|
||||||
|
# Pulisci il badge da eventuali caratteri sentinel
|
||||||
|
clean_badge = request.badge.strip().replace(";", "").replace("?", "")
|
||||||
|
|
||||||
|
if clean_badge != VALIDATOR_BADGE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Badge validatore non riconosciuto"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.password != VALIDATOR_PASSWORD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Password non corretta"
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
success=True,
|
||||||
|
message="Login validatore effettuato con successo",
|
||||||
|
token=MOCK_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/anagrafica/{badge_code}", response_model=UserResponse)
|
||||||
|
async def get_user_anagrafica(badge_code: str):
|
||||||
|
"""
|
||||||
|
Cerca un utente tramite il suo badge code.
|
||||||
|
Restituisce i dati anagrafici e un warning se non è ammesso.
|
||||||
|
"""
|
||||||
|
# Pulisci il badge da eventuali caratteri sentinel
|
||||||
|
clean_badge = badge_code.strip().replace(";", "").replace("?", "")
|
||||||
|
|
||||||
|
# Normalizza: rimuovi zeri iniziali per il confronto, ma cerca anche con zeri
|
||||||
|
for user in USERS_DB:
|
||||||
|
if user["badge_code"] == clean_badge or user["badge_code"].lstrip("0") == clean_badge.lstrip("0"):
|
||||||
|
response = UserResponse(
|
||||||
|
badge_code=user["badge_code"],
|
||||||
|
nome=user["nome"],
|
||||||
|
cognome=user["cognome"],
|
||||||
|
url_foto=user["url_foto"],
|
||||||
|
ruolo=user["ruolo"],
|
||||||
|
ammesso=user["ammesso"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggiungi warning se non ammesso
|
||||||
|
if not user["ammesso"]:
|
||||||
|
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Utente non trovato
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Utente con badge {clean_badge} non trovato nel sistema"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/entry-request", response_model=EntryResponse)
|
||||||
|
async def process_entry_request(request: EntryRequest):
|
||||||
|
"""
|
||||||
|
Processa una richiesta di ingresso.
|
||||||
|
Richiede il badge dell'utente e la password del validatore.
|
||||||
|
"""
|
||||||
|
# Pulisci i dati
|
||||||
|
clean_user_badge = request.user_badge.strip().replace(";", "").replace("?", "")
|
||||||
|
|
||||||
|
# Verifica password validatore
|
||||||
|
if request.validator_password != VALIDATOR_PASSWORD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Password validatore non corretta"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cerca l'utente
|
||||||
|
user_found = None
|
||||||
|
for user in USERS_DB:
|
||||||
|
if user["badge_code"] == clean_user_badge or user["badge_code"].lstrip("0") == clean_user_badge.lstrip("0"):
|
||||||
|
user_found = user
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_found:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Utente con badge {clean_user_badge} non trovato"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_found["ammesso"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"L'utente {user_found['nome']} {user_found['cognome']} NON è autorizzato all'ingresso"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Successo! Genera messaggio di benvenuto casuale
|
||||||
|
welcome = random.choice(WELCOME_MESSAGES)
|
||||||
|
|
||||||
|
return EntryResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Ingresso registrato per {user_found['nome']} {user_found['cognome']}",
|
||||||
|
welcome_message=welcome
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AVVIO SERVER
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
print("🚀 Avvio Focolari Voting System Backend...")
|
||||||
|
print("📍 Server in ascolto su http://localhost:8000")
|
||||||
|
print("📚 Documentazione API su http://localhost:8000/docs")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
49
frontend/.gitignore
vendored
Normal file
49
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# ================================================
|
||||||
|
# Focolari Voting System - Frontend .gitignore
|
||||||
|
# ================================================
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
.eslintcache
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3924
frontend/package-lock.json
generated
Normal file
3924
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
376
frontend/src/App.tsx
Normal file
376
frontend/src/App.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* Focolari Voting System - Main Application
|
||||||
|
* State Machine per il controllo accessi
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRFIDScanner } from './hooks/useRFIDScanner';
|
||||||
|
import {
|
||||||
|
LoadingScreen,
|
||||||
|
ValidatorLoginScreen,
|
||||||
|
ActiveGateScreen,
|
||||||
|
SuccessModal,
|
||||||
|
ErrorModal,
|
||||||
|
} from './screens';
|
||||||
|
import {
|
||||||
|
getRoomInfo,
|
||||||
|
loginValidator,
|
||||||
|
getUserByBadge,
|
||||||
|
requestEntry,
|
||||||
|
ApiError,
|
||||||
|
} from './services/api';
|
||||||
|
import type { AppState, RoomInfo, User, ValidatorSession } from './types';
|
||||||
|
|
||||||
|
// Costanti
|
||||||
|
const VALIDATOR_BADGE = '999999';
|
||||||
|
const VALIDATOR_PASSWORD = 'focolari';
|
||||||
|
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti
|
||||||
|
const USER_TIMEOUT_SECONDS = 60;
|
||||||
|
const STORAGE_KEY = 'focolari_validator_session';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// ============================================
|
||||||
|
// State
|
||||||
|
// ============================================
|
||||||
|
const [appState, setAppState] = useState<AppState>('loading');
|
||||||
|
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
|
||||||
|
const [validatorSession, setValidatorSession] = useState<ValidatorSession | null>(null);
|
||||||
|
const [pendingValidatorBadge, setPendingValidatorBadge] = useState<string | null>(null);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||||
|
const [errorModalMessage, setErrorModalMessage] = useState('');
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Session Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const saveSession = useCallback((session: ValidatorSession) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||||
|
setValidatorSession(session);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setValidatorSession(null);
|
||||||
|
setPendingValidatorBadge(null);
|
||||||
|
setCurrentUser(null);
|
||||||
|
setAppState('waiting-validator');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSession = useCallback((): ValidatorSession | null => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const session: ValidatorSession = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Check if session is expired
|
||||||
|
if (Date.now() > session.expiresAt) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RFID Scanner Handler
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const handleRFIDScan = useCallback(async (code: string) => {
|
||||||
|
console.log('[App] Badge scansionato:', code);
|
||||||
|
|
||||||
|
// Pulisci il codice
|
||||||
|
const cleanCode = code.trim();
|
||||||
|
|
||||||
|
switch (appState) {
|
||||||
|
case 'waiting-validator':
|
||||||
|
// Verifica se è un badge validatore
|
||||||
|
if (cleanCode === VALIDATOR_BADGE) {
|
||||||
|
setPendingValidatorBadge(cleanCode);
|
||||||
|
setAppState('validator-password');
|
||||||
|
} else {
|
||||||
|
setError('Badge validatore non riconosciuto');
|
||||||
|
setTimeout(() => setError(undefined), 3000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'validator-password':
|
||||||
|
// Ignora badge durante inserimento password
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gate-active':
|
||||||
|
case 'showing-user':
|
||||||
|
// Se è il badge del validatore e c'è un utente a schermo
|
||||||
|
if (cleanCode === VALIDATOR_BADGE && currentUser && currentUser.ammesso) {
|
||||||
|
// Conferma ingresso
|
||||||
|
await handleEntryConfirm();
|
||||||
|
} else if (cleanCode !== VALIDATOR_BADGE) {
|
||||||
|
// Nuovo badge partecipante - carica utente
|
||||||
|
await handleLoadUser(cleanCode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [appState, currentUser]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Initialize RFID Scanner
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const { state: rfidState, buffer: rfidBuffer } = useRFIDScanner({
|
||||||
|
onScan: handleRFIDScan,
|
||||||
|
onTimeout: () => {
|
||||||
|
console.warn('[App] RFID timeout - lettura incompleta');
|
||||||
|
},
|
||||||
|
disabled: appState === 'loading',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API Handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const handleLoadUser = useCallback(async (badgeCode: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
setAppState('showing-user');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUserByBadge(badgeCode);
|
||||||
|
setCurrentUser(user);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError
|
||||||
|
? err.detail || err.message
|
||||||
|
: 'Errore durante il caricamento utente';
|
||||||
|
setError(message);
|
||||||
|
setCurrentUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEntryConfirm = useCallback(async () => {
|
||||||
|
if (!currentUser || !validatorSession) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await requestEntry(
|
||||||
|
currentUser.badge_code,
|
||||||
|
VALIDATOR_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setSuccessMessage(response.welcome_message || 'Benvenuto!');
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
setCurrentUser(null);
|
||||||
|
setAppState('gate-active');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError
|
||||||
|
? err.detail || err.message
|
||||||
|
: 'Errore durante la registrazione ingresso';
|
||||||
|
setErrorModalMessage(message);
|
||||||
|
setShowErrorModal(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentUser, validatorSession]);
|
||||||
|
|
||||||
|
const handlePasswordSubmit = useCallback(async (password: string) => {
|
||||||
|
if (!pendingValidatorBadge) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await loginValidator(pendingValidatorBadge, password);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const session: ValidatorSession = {
|
||||||
|
badge: pendingValidatorBadge,
|
||||||
|
token: response.token || '',
|
||||||
|
loginTime: Date.now(),
|
||||||
|
expiresAt: Date.now() + SESSION_DURATION_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveSession(session);
|
||||||
|
setPendingValidatorBadge(null);
|
||||||
|
setAppState('gate-active');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError
|
||||||
|
? err.detail || err.message
|
||||||
|
: 'Errore durante il login';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pendingValidatorBadge, saveSession]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UI Handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const handleCancelPassword = useCallback(() => {
|
||||||
|
setPendingValidatorBadge(null);
|
||||||
|
setError(undefined);
|
||||||
|
setAppState('waiting-validator');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelUser = useCallback(() => {
|
||||||
|
setCurrentUser(null);
|
||||||
|
setError(undefined);
|
||||||
|
setAppState('gate-active');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUserTimeout = useCallback(() => {
|
||||||
|
console.log('[App] User timeout - tornando in attesa');
|
||||||
|
setCurrentUser(null);
|
||||||
|
setError(undefined);
|
||||||
|
setAppState('gate-active');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSuccessModalClose = useCallback(() => {
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
setSuccessMessage('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleErrorModalClose = useCallback(() => {
|
||||||
|
setShowErrorModal(false);
|
||||||
|
setErrorModalMessage('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Initialization
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
// Carica info sala
|
||||||
|
const info = await getRoomInfo();
|
||||||
|
setRoomInfo(info);
|
||||||
|
|
||||||
|
// Verifica sessione esistente
|
||||||
|
const existingSession = loadSession();
|
||||||
|
|
||||||
|
if (existingSession) {
|
||||||
|
setValidatorSession(existingSession);
|
||||||
|
setAppState('gate-active');
|
||||||
|
} else {
|
||||||
|
setAppState('waiting-validator');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError
|
||||||
|
? err.detail || err.message
|
||||||
|
: 'Impossibile connettersi al server';
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
}, [loadSession]);
|
||||||
|
|
||||||
|
// Session expiry check
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validatorSession) return;
|
||||||
|
|
||||||
|
const checkExpiry = () => {
|
||||||
|
if (Date.now() > validatorSession.expiresAt) {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkExpiry, 60000); // Check every minute
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [validatorSession, clearSession]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Render
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (appState === 'loading') {
|
||||||
|
return (
|
||||||
|
<LoadingScreen
|
||||||
|
message="Connessione al server..."
|
||||||
|
error={error}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No room info - error
|
||||||
|
if (!roomInfo) {
|
||||||
|
return (
|
||||||
|
<LoadingScreen
|
||||||
|
error={error || 'Impossibile caricare le informazioni della sala'}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validator login screens
|
||||||
|
if (appState === 'waiting-validator' || appState === 'validator-password') {
|
||||||
|
return (
|
||||||
|
<ValidatorLoginScreen
|
||||||
|
roomInfo={roomInfo}
|
||||||
|
rfidState={rfidState}
|
||||||
|
rfidBuffer={rfidBuffer}
|
||||||
|
validatorBadge={pendingValidatorBadge}
|
||||||
|
onPasswordSubmit={handlePasswordSubmit}
|
||||||
|
onCancel={handleCancelPassword}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active gate screen
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActiveGateScreen
|
||||||
|
roomInfo={roomInfo}
|
||||||
|
rfidState={rfidState}
|
||||||
|
rfidBuffer={rfidBuffer}
|
||||||
|
currentUser={currentUser}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onCancelUser={handleCancelUser}
|
||||||
|
onLogout={clearSession}
|
||||||
|
userTimeoutSeconds={USER_TIMEOUT_SECONDS}
|
||||||
|
onUserTimeout={handleUserTimeout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Success Modal */}
|
||||||
|
<SuccessModal
|
||||||
|
isOpen={showSuccessModal}
|
||||||
|
onClose={handleSuccessModalClose}
|
||||||
|
welcomeMessage={successMessage}
|
||||||
|
userName={currentUser ? `${currentUser.nome} ${currentUser.cognome}` : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error Modal */}
|
||||||
|
<ErrorModal
|
||||||
|
isOpen={showErrorModal}
|
||||||
|
onClose={handleErrorModalClose}
|
||||||
|
message={errorModalMessage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
frontend/src/assets/FocolareMovLogo.jpg
Normal file
BIN
frontend/src/assets/FocolareMovLogo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
90
frontend/src/components/Button.tsx
Normal file
90
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Button Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullWidth?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseClasses =
|
||||||
|
'font-semibold rounded-xl transition-all duration-200 ' +
|
||||||
|
'focus:outline-none focus:ring-4 focus:ring-offset-2 ' +
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed ' +
|
||||||
|
'active:scale-95 touch-target';
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
'bg-focolare-blue hover:bg-focolare-blue-dark text-white ' +
|
||||||
|
'focus:ring-focolare-blue/50',
|
||||||
|
secondary:
|
||||||
|
'bg-gray-200 hover:bg-gray-300 text-gray-800 ' +
|
||||||
|
'focus:ring-gray-400/50',
|
||||||
|
danger:
|
||||||
|
'bg-error hover:bg-error-dark text-white ' +
|
||||||
|
'focus:ring-error/50',
|
||||||
|
success:
|
||||||
|
'bg-success hover:bg-success-dark text-white ' +
|
||||||
|
'focus:ring-success/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-4 py-2 text-sm',
|
||||||
|
md: 'px-6 py-3 text-base',
|
||||||
|
lg: 'px-8 py-4 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
${baseClasses}
|
||||||
|
${variantClasses[variant]}
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${widthClass}
|
||||||
|
${className}
|
||||||
|
`.trim()}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Caricamento...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button;
|
||||||
101
frontend/src/components/CountdownTimer.tsx
Normal file
101
frontend/src/components/CountdownTimer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Countdown Timer Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface CountdownTimerProps {
|
||||||
|
/** Secondi totali */
|
||||||
|
seconds: number;
|
||||||
|
/** Callback quando il timer scade */
|
||||||
|
onExpire: () => void;
|
||||||
|
/** Pausa il timer */
|
||||||
|
paused?: boolean;
|
||||||
|
/** Mostra come barra di progresso */
|
||||||
|
showBar?: boolean;
|
||||||
|
/** Soglia warning (colore giallo) */
|
||||||
|
warningThreshold?: number;
|
||||||
|
/** Soglia danger (colore rosso) */
|
||||||
|
dangerThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownTimer({
|
||||||
|
seconds,
|
||||||
|
onExpire,
|
||||||
|
paused = false,
|
||||||
|
showBar = true,
|
||||||
|
warningThreshold = 30,
|
||||||
|
dangerThreshold = 10,
|
||||||
|
}: CountdownTimerProps) {
|
||||||
|
const [remaining, setRemaining] = useState(seconds);
|
||||||
|
|
||||||
|
const formatTime = useCallback((secs: number): string => {
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
const secsLeft = secs % 60;
|
||||||
|
return `${mins}:${secsLeft.toString().padStart(2, '0')}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(seconds);
|
||||||
|
}, [seconds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || remaining <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setRemaining((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
onExpire();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [paused, remaining, onExpire]);
|
||||||
|
|
||||||
|
const getColorClass = (): string => {
|
||||||
|
if (remaining <= dangerThreshold) {
|
||||||
|
return 'text-error';
|
||||||
|
}
|
||||||
|
if (remaining <= warningThreshold) {
|
||||||
|
return 'text-warning';
|
||||||
|
}
|
||||||
|
return 'text-focolare-blue';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBarColorClass = (): string => {
|
||||||
|
if (remaining <= dangerThreshold) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
if (remaining <= warningThreshold) {
|
||||||
|
return 'bg-warning';
|
||||||
|
}
|
||||||
|
return 'bg-focolare-blue';
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = (remaining / seconds) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className={`text-2xl font-bold font-mono ${getColorClass()}`}>
|
||||||
|
{formatTime(remaining)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBar && (
|
||||||
|
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-1000 ease-linear ${getBarColorClass()}`}
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CountdownTimer;
|
||||||
48
frontend/src/components/Input.tsx
Normal file
48
frontend/src/components/Input.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Input Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, fullWidth = true, className = '', ...props }, ref) => {
|
||||||
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${widthClass} ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
w-full px-4 py-3 text-lg
|
||||||
|
border-2 rounded-xl
|
||||||
|
transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-4 focus:ring-focolare-blue/30
|
||||||
|
${error
|
||||||
|
? 'border-error focus:border-error'
|
||||||
|
: 'border-gray-300 focus:border-focolare-blue'
|
||||||
|
}
|
||||||
|
`.trim()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-sm text-error font-medium">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export default Input;
|
||||||
44
frontend/src/components/Logo.tsx
Normal file
44
frontend/src/components/Logo.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Logo Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import FocolareLogo from '../assets/FocolareMovLogo.jpg';
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ size = 'md', showText = true }: LogoProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-10 w-10',
|
||||||
|
md: 'h-14 w-14',
|
||||||
|
lg: 'h-20 w-20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textSizeClasses = {
|
||||||
|
sm: 'text-lg',
|
||||||
|
md: 'text-xl',
|
||||||
|
lg: 'text-2xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={FocolareLogo}
|
||||||
|
alt="Movimento dei Focolari"
|
||||||
|
className={`${sizeClasses[size]} rounded-lg object-contain shadow-md`}
|
||||||
|
/>
|
||||||
|
{showText && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`${textSizeClasses[size]} font-bold text-focolare-blue`}>
|
||||||
|
Movimento dei Focolari
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">Sistema Votazioni</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
80
frontend/src/components/Modal.tsx
Normal file
80
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Modal Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
variant?: 'success' | 'error' | 'info';
|
||||||
|
autoCloseMs?: number;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
variant = 'info',
|
||||||
|
autoCloseMs,
|
||||||
|
fullscreen = false,
|
||||||
|
children,
|
||||||
|
}: ModalProps) {
|
||||||
|
// Auto-close functionality
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !autoCloseMs || !onClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, autoCloseMs);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen, autoCloseMs, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
success: 'bg-success',
|
||||||
|
error: 'bg-error',
|
||||||
|
info: 'bg-focolare-blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayClass = fullscreen
|
||||||
|
? `fixed inset-0 z-50 ${variantClasses[variant]} animate-fade-in`
|
||||||
|
: 'fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4 animate-fade-in';
|
||||||
|
|
||||||
|
const contentClass = fullscreen
|
||||||
|
? 'h-full w-full flex items-center justify-center'
|
||||||
|
: 'glass rounded-2xl shadow-2xl max-w-lg w-full p-6 animate-slide-up';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={overlayClass} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={contentClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
33
frontend/src/components/RFIDStatus.tsx
Normal file
33
frontend/src/components/RFIDStatus.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* RFID Status Indicator - Focolari Voting System
|
||||||
|
* Mostra lo stato del lettore RFID
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RFIDScannerState } from '../types';
|
||||||
|
|
||||||
|
interface RFIDStatusProps {
|
||||||
|
state: RFIDScannerState;
|
||||||
|
buffer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RFIDStatus({ state, buffer }: RFIDStatusProps) {
|
||||||
|
if (state === 'idle') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-gray-300" />
|
||||||
|
<span className="text-sm">RFID Pronto</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-focolare-orange">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-focolare-orange animate-pulse" />
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
Lettura in corso... {buffer && `(${buffer.length} caratteri)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RFIDStatus;
|
||||||
100
frontend/src/components/UserCard.tsx
Normal file
100
frontend/src/components/UserCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* User Card Component - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: User;
|
||||||
|
size?: 'compact' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCard({ user, size = 'full' }: UserCardProps) {
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
Votante: 'bg-focolare-blue text-white',
|
||||||
|
Tecnico: 'bg-focolare-orange text-white',
|
||||||
|
Ospite: 'bg-gray-500 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = user.ammesso
|
||||||
|
? 'border-success bg-success/10'
|
||||||
|
: 'border-error bg-error/10 animate-pulse-error';
|
||||||
|
|
||||||
|
if (size === 'compact') {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border-2 p-4 ${statusClass} animate-slide-up`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={user.url_foto}
|
||||||
|
alt={`${user.nome} ${user.cognome}`}
|
||||||
|
className="h-16 w-16 rounded-full object-cover shadow-md"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
'https://via.placeholder.com/100?text=' + user.nome.charAt(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-800">
|
||||||
|
{user.nome} {user.cognome}
|
||||||
|
</h3>
|
||||||
|
<span className={`inline-block px-2 py-1 text-sm rounded ${roleColors[user.ruolo]}`}>
|
||||||
|
{user.ruolo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border-4 p-6 ${statusClass} animate-slide-up`}>
|
||||||
|
{/* Foto e Dati Principali */}
|
||||||
|
<div className="flex flex-col items-center gap-4 md:flex-row md:items-start md:gap-6">
|
||||||
|
<img
|
||||||
|
src={user.url_foto}
|
||||||
|
alt={`${user.nome} ${user.cognome}`}
|
||||||
|
className="h-32 w-32 rounded-2xl object-cover shadow-lg md:h-40 md:w-40"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
'https://via.placeholder.com/200?text=' + user.nome.charAt(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center text-center md:items-start md:text-left">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 md:text-4xl">
|
||||||
|
{user.nome} {user.cognome}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${roleColors[user.ruolo]}`}>
|
||||||
|
{user.ruolo}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={`px-4 py-2 text-lg font-semibold rounded-full ${
|
||||||
|
user.ammesso
|
||||||
|
? 'bg-success text-white'
|
||||||
|
: 'bg-error text-white animate-blink'
|
||||||
|
}`}>
|
||||||
|
{user.ammesso ? '✓ AMMESSO' : '✗ NON AMMESSO'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-gray-500">
|
||||||
|
Badge: <span className="font-mono font-semibold">{user.badge_code}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Box */}
|
||||||
|
{user.warning && (
|
||||||
|
<div className="mt-4 rounded-xl bg-error/20 border-2 border-error p-4">
|
||||||
|
<p className="text-lg font-bold text-error text-center">
|
||||||
|
⚠️ {user.warning}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserCard;
|
||||||
8
frontend/src/components/index.ts
Normal file
8
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Components barrel export
|
||||||
|
export { Logo } from './Logo';
|
||||||
|
export { UserCard } from './UserCard';
|
||||||
|
export { CountdownTimer } from './CountdownTimer';
|
||||||
|
export { Modal } from './Modal';
|
||||||
|
export { RFIDStatus } from './RFIDStatus';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export { Input } from './Input';
|
||||||
219
frontend/src/hooks/useRFIDScanner.ts
Normal file
219
frontend/src/hooks/useRFIDScanner.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Focolari Voting System - RFID Scanner Hook
|
||||||
|
*
|
||||||
|
* Questo hook gestisce la lettura di badge RFID tramite lettori USB
|
||||||
|
* che emulano una tastiera. Il protocollo prevede:
|
||||||
|
* - Carattere di inizio: `;`
|
||||||
|
* - Carattere di fine: `?`
|
||||||
|
* - Esempio: `;00012345?`
|
||||||
|
*
|
||||||
|
* L'hook funziona indipendentemente dal focus dell'applicazione.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { RFIDScannerState, RFIDScanResult } from '../types';
|
||||||
|
|
||||||
|
// Costanti
|
||||||
|
const START_SENTINEL = ';';
|
||||||
|
const END_SENTINEL = '?';
|
||||||
|
const TIMEOUT_MS = 3000; // 3 secondi di timeout
|
||||||
|
|
||||||
|
interface UseRFIDScannerOptions {
|
||||||
|
/** Callback chiamato quando un badge viene letto con successo */
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
/** Callback opzionale chiamato in caso di timeout */
|
||||||
|
onTimeout?: () => void;
|
||||||
|
/** Callback opzionale chiamato quando inizia la scansione */
|
||||||
|
onScanStart?: () => void;
|
||||||
|
/** Se true, previene l'input nei campi di testo durante la scansione */
|
||||||
|
preventDefaultOnScan?: boolean;
|
||||||
|
/** Se true, l'hook è disabilitato */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseRFIDScannerReturn {
|
||||||
|
/** Stato corrente dello scanner */
|
||||||
|
state: RFIDScannerState;
|
||||||
|
/** Buffer corrente (solo per debug) */
|
||||||
|
buffer: string;
|
||||||
|
/** Ultimo codice scansionato */
|
||||||
|
lastScan: RFIDScanResult | null;
|
||||||
|
/** Reset manuale dello scanner */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRFIDScanner({
|
||||||
|
onScan,
|
||||||
|
onTimeout,
|
||||||
|
onScanStart,
|
||||||
|
preventDefaultOnScan = true,
|
||||||
|
disabled = false,
|
||||||
|
}: UseRFIDScannerOptions): UseRFIDScannerReturn {
|
||||||
|
const [state, setState] = useState<RFIDScannerState>('idle');
|
||||||
|
const [buffer, setBuffer] = useState<string>('');
|
||||||
|
const [lastScan, setLastScan] = useState<RFIDScanResult | null>(null);
|
||||||
|
|
||||||
|
// Refs per mantenere i valori aggiornati nei callback
|
||||||
|
const bufferRef = useRef<string>('');
|
||||||
|
const stateRef = useRef<RFIDScannerState>('idle');
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Sync refs con state
|
||||||
|
useEffect(() => {
|
||||||
|
bufferRef.current = buffer;
|
||||||
|
}, [buffer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisce il timeout attivo
|
||||||
|
*/
|
||||||
|
const clearScanTimeout = useCallback(() => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetta lo scanner allo stato idle
|
||||||
|
*/
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
clearScanTimeout();
|
||||||
|
setState('idle');
|
||||||
|
setBuffer('');
|
||||||
|
bufferRef.current = '';
|
||||||
|
stateRef.current = 'idle';
|
||||||
|
}, [clearScanTimeout]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avvia il timeout di sicurezza
|
||||||
|
*/
|
||||||
|
const startTimeout = useCallback(() => {
|
||||||
|
clearScanTimeout();
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
console.warn('[RFID Scanner] Timeout - lettura incompleta scartata');
|
||||||
|
onTimeout?.();
|
||||||
|
reset();
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
}, [clearScanTimeout, onTimeout, reset]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler principale per gli eventi keydown
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
// Ignora tasti speciali (frecce, funzione, ecc.)
|
||||||
|
if (key.length > 1 && key !== START_SENTINEL && key !== END_SENTINEL) {
|
||||||
|
// Eccezione per Backspace in stato scanning: ignora ma non resetta
|
||||||
|
if (key === 'Backspace' && stateRef.current === 'scanning') {
|
||||||
|
if (preventDefaultOnScan) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATO IDLE: attende il carattere di inizio
|
||||||
|
if (stateRef.current === 'idle') {
|
||||||
|
if (key === START_SENTINEL) {
|
||||||
|
console.log('[RFID Scanner] Start sentinel rilevato - inizio scansione');
|
||||||
|
|
||||||
|
if (preventDefaultOnScan) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('scanning');
|
||||||
|
setBuffer('');
|
||||||
|
bufferRef.current = '';
|
||||||
|
startTimeout();
|
||||||
|
onScanStart?.();
|
||||||
|
}
|
||||||
|
// Altrimenti ignora il tasto (comportamento normale)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATO SCANNING: accumula i caratteri o termina
|
||||||
|
if (stateRef.current === 'scanning') {
|
||||||
|
if (preventDefaultOnScan) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === END_SENTINEL) {
|
||||||
|
// Fine della scansione
|
||||||
|
clearScanTimeout();
|
||||||
|
|
||||||
|
const scannedCode = bufferRef.current.trim();
|
||||||
|
|
||||||
|
if (scannedCode.length > 0) {
|
||||||
|
console.log('[RFID Scanner] Codice scansionato:', scannedCode);
|
||||||
|
|
||||||
|
const result: RFIDScanResult = {
|
||||||
|
code: scannedCode,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setLastScan(result);
|
||||||
|
onScan(scannedCode);
|
||||||
|
} else {
|
||||||
|
console.warn('[RFID Scanner] Codice vuoto scartato');
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
} else if (key === START_SENTINEL) {
|
||||||
|
// Nuovo start sentinel durante scansione: resetta e ricomincia
|
||||||
|
console.log('[RFID Scanner] Nuovo start sentinel - reset buffer');
|
||||||
|
setBuffer('');
|
||||||
|
bufferRef.current = '';
|
||||||
|
startTimeout();
|
||||||
|
} else {
|
||||||
|
// Accumula il carattere nel buffer
|
||||||
|
const newBuffer = bufferRef.current + key;
|
||||||
|
setBuffer(newBuffer);
|
||||||
|
bufferRef.current = newBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggiungi listener globale
|
||||||
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
clearScanTimeout();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
disabled,
|
||||||
|
onScan,
|
||||||
|
onScanStart,
|
||||||
|
preventDefaultOnScan,
|
||||||
|
clearScanTimeout,
|
||||||
|
reset,
|
||||||
|
startTimeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cleanup al unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearScanTimeout();
|
||||||
|
};
|
||||||
|
}, [clearScanTimeout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
buffer,
|
||||||
|
lastScan,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useRFIDScanner;
|
||||||
154
frontend/src/index.css
Normal file
154
frontend/src/index.css
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FOCOLARI VOTING SYSTEM - Design Tokens
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Colori Istituzionali */
|
||||||
|
--color-focolare-blue: #0072CE;
|
||||||
|
--color-focolare-blue-dark: #005BA1;
|
||||||
|
--color-focolare-blue-light: #3D9BE0;
|
||||||
|
|
||||||
|
--color-focolare-orange: #F5A623;
|
||||||
|
--color-focolare-orange-dark: #D4891C;
|
||||||
|
--color-focolare-orange-light: #FFB84D;
|
||||||
|
|
||||||
|
--color-focolare-yellow: #FFD700;
|
||||||
|
--color-focolare-yellow-dark: #CCA300;
|
||||||
|
--color-focolare-yellow-light: #FFE44D;
|
||||||
|
|
||||||
|
/* Stati */
|
||||||
|
--color-success: #22C55E;
|
||||||
|
--color-success-dark: #16A34A;
|
||||||
|
--color-error: #EF4444;
|
||||||
|
--color-error-dark: #DC2626;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Global Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
l:root {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Tablet-Optimized Touch Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
button,
|
||||||
|
.touch-target {
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-size: 16px; /* Previene zoom su iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Animazioni
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 40px rgba(34, 197, 94, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-error {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
25%, 75% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-glow {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-error {
|
||||||
|
animation: pulse-error 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blink {
|
||||||
|
animation: blink 1.5s step-start infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Utility Classes
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.text-shadow {
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
213
frontend/src/screens/ActiveGateScreen.tsx
Normal file
213
frontend/src/screens/ActiveGateScreen.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Active Gate Screen - Focolari Voting System
|
||||||
|
* Schermata principale del varco attivo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components';
|
||||||
|
import type { RoomInfo, User, RFIDScannerState } from '../types';
|
||||||
|
|
||||||
|
interface ActiveGateScreenProps {
|
||||||
|
roomInfo: RoomInfo;
|
||||||
|
rfidState: RFIDScannerState;
|
||||||
|
rfidBuffer: string;
|
||||||
|
currentUser: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
onCancelUser: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
userTimeoutSeconds?: number;
|
||||||
|
onUserTimeout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveGateScreen({
|
||||||
|
roomInfo,
|
||||||
|
rfidState,
|
||||||
|
rfidBuffer,
|
||||||
|
currentUser,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onCancelUser,
|
||||||
|
onLogout,
|
||||||
|
userTimeoutSeconds = 60,
|
||||||
|
onUserTimeout,
|
||||||
|
}: ActiveGateScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-4 md:p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||||
|
<Logo size="md" />
|
||||||
|
<div className="flex items-center gap-4 md:gap-8">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
Esci
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex items-center justify-center p-4 md:p-8">
|
||||||
|
{loading ? (
|
||||||
|
// Loading state
|
||||||
|
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-focolare-blue/10 mb-6">
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-10 w-10 text-focolare-blue"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl text-gray-600">Caricamento dati...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
// Error state
|
||||||
|
<div className="glass rounded-3xl p-12 shadow-xl animate-slide-up text-center max-w-md">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-error/10 mb-6">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-error"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl text-error font-semibold mb-4">{error}</p>
|
||||||
|
<Button variant="secondary" onClick={onCancelUser}>
|
||||||
|
Chiudi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : currentUser ? (
|
||||||
|
// User found - Decision screen
|
||||||
|
<div className="w-full max-w-4xl animate-slide-up">
|
||||||
|
<div className="glass rounded-3xl p-6 md:p-10 shadow-xl">
|
||||||
|
{/* Timer bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<CountdownTimer
|
||||||
|
seconds={userTimeoutSeconds}
|
||||||
|
onExpire={onUserTimeout}
|
||||||
|
warningThreshold={20}
|
||||||
|
dangerThreshold={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Card */}
|
||||||
|
<UserCard user={currentUser} size="full" />
|
||||||
|
|
||||||
|
{/* Action Hint */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
{currentUser.ammesso ? (
|
||||||
|
<div className="py-6 px-8 bg-success/10 rounded-2xl border-2 border-success/30">
|
||||||
|
<p className="text-xl text-success font-semibold mb-2">
|
||||||
|
✓ Utente ammesso all'ingresso
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Passa il <span className="font-bold text-focolare-blue">badge VALIDATORE</span> per confermare l'accesso
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-6 px-8 bg-error/10 rounded-2xl border-2 border-error/30">
|
||||||
|
<p className="text-xl text-error font-bold mb-2 animate-blink">
|
||||||
|
⚠️ ACCESSO NON CONSENTITO
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Questo utente non è autorizzato ad entrare
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onCancelUser}
|
||||||
|
>
|
||||||
|
✕ Annulla
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Idle - Waiting for participant
|
||||||
|
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in text-center max-w-xl">
|
||||||
|
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-focolare-blue/10 mb-8">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 text-focolare-blue"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold text-focolare-blue mb-4">
|
||||||
|
Varco Attivo
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-2xl text-gray-600 mb-8">
|
||||||
|
In attesa del partecipante...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="py-8 px-6 bg-focolare-orange/10 rounded-2xl border-2 border-dashed border-focolare-orange/40">
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 text-focolare-orange animate-pulse"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-2xl text-focolare-orange font-medium">
|
||||||
|
Passa il badge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer with RFID Status */}
|
||||||
|
<footer className="p-4 border-t bg-white/50 flex items-center justify-between">
|
||||||
|
<RFIDStatus state={rfidState} buffer={rfidBuffer} />
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Varco attivo • {new Date().toLocaleTimeString('it-IT')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActiveGateScreen;
|
||||||
72
frontend/src/screens/ErrorModal.tsx
Normal file
72
frontend/src/screens/ErrorModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Error Modal - Focolari Voting System
|
||||||
|
* Modal per errori
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Modal, Button } from '../components';
|
||||||
|
|
||||||
|
interface ErrorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title = 'Errore',
|
||||||
|
message
|
||||||
|
}: ErrorModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
variant="error"
|
||||||
|
fullscreen
|
||||||
|
>
|
||||||
|
<div className="text-center text-white p-8 max-w-2xl">
|
||||||
|
{/* Error Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-error">
|
||||||
|
<svg
|
||||||
|
className="w-20 h-20 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold mb-6 animate-slide-up">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
<p className="text-2xl md:text-3xl opacity-90 mb-12 animate-fade-in">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-white text-error hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Chiudi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorModal;
|
||||||
90
frontend/src/screens/LoadingScreen.tsx
Normal file
90
frontend/src/screens/LoadingScreen.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Loading Screen - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logo } from '../components';
|
||||||
|
|
||||||
|
interface LoadingScreenProps {
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingScreen({
|
||||||
|
message = 'Connessione al server...',
|
||||||
|
error,
|
||||||
|
onRetry
|
||||||
|
}: LoadingScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-br from-focolare-blue/5 to-focolare-blue/20">
|
||||||
|
<div className="glass rounded-3xl p-12 shadow-xl animate-fade-in max-w-lg w-full text-center">
|
||||||
|
<Logo size="lg" showText={false} />
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-3xl font-bold text-focolare-blue">
|
||||||
|
Focolari Voting System
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{!error ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-focolare-blue/10">
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-8 w-8 text-focolare-blue"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-gray-600 text-lg">{message}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/10">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-error"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-error text-lg font-semibold">{error}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-6 px-8 py-3 bg-focolare-blue text-white rounded-xl
|
||||||
|
font-semibold hover:bg-focolare-blue-dark transition-colors"
|
||||||
|
>
|
||||||
|
Riprova
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingScreen;
|
||||||
88
frontend/src/screens/SuccessModal.tsx
Normal file
88
frontend/src/screens/SuccessModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Success Modal - Focolari Voting System
|
||||||
|
* Modal fullscreen per conferma ingresso
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Modal } from '../components';
|
||||||
|
|
||||||
|
interface SuccessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
welcomeMessage: string;
|
||||||
|
userName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuccessModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
welcomeMessage,
|
||||||
|
userName
|
||||||
|
}: SuccessModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
variant="success"
|
||||||
|
autoCloseMs={5000}
|
||||||
|
fullscreen
|
||||||
|
>
|
||||||
|
<div className="text-center text-white p-8">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-white/20 animate-pulse-glow">
|
||||||
|
<svg
|
||||||
|
className="w-20 h-20 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={3}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Name */}
|
||||||
|
{userName && (
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold mb-4 animate-slide-up">
|
||||||
|
{userName}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Welcome Message */}
|
||||||
|
<h1 className="text-5xl md:text-7xl font-bold mb-8 animate-slide-up">
|
||||||
|
{welcomeMessage}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Sub text */}
|
||||||
|
<p className="text-2xl md:text-3xl opacity-80 animate-fade-in">
|
||||||
|
Ingresso registrato con successo
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Auto-close indicator */}
|
||||||
|
<div className="mt-12">
|
||||||
|
<div className="w-64 h-2 bg-white/30 rounded-full mx-auto overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white rounded-full"
|
||||||
|
style={{
|
||||||
|
animation: 'shrink 5s linear forwards',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes shrink {
|
||||||
|
from { width: 100%; }
|
||||||
|
to { width: 0%; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SuccessModal;
|
||||||
179
frontend/src/screens/ValidatorLoginScreen.tsx
Normal file
179
frontend/src/screens/ValidatorLoginScreen.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Validator Login Screen - Focolari Voting System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Logo, Button, Input, RFIDStatus } from '../components';
|
||||||
|
import type { RoomInfo, RFIDScannerState } from '../types';
|
||||||
|
|
||||||
|
interface ValidatorLoginScreenProps {
|
||||||
|
roomInfo: RoomInfo;
|
||||||
|
rfidState: RFIDScannerState;
|
||||||
|
rfidBuffer: string;
|
||||||
|
validatorBadge: string | null;
|
||||||
|
onPasswordSubmit: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
error?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidatorLoginScreen({
|
||||||
|
roomInfo,
|
||||||
|
rfidState,
|
||||||
|
rfidBuffer,
|
||||||
|
validatorBadge,
|
||||||
|
onPasswordSubmit,
|
||||||
|
onCancel,
|
||||||
|
error,
|
||||||
|
loading = false,
|
||||||
|
}: ValidatorLoginScreenProps) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Focus input quando appare il form password
|
||||||
|
useEffect(() => {
|
||||||
|
if (validatorBadge && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [validatorBadge]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password.trim()) {
|
||||||
|
onPasswordSubmit(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-6 flex items-center justify-between border-b bg-white/80 backdrop-blur shadow-sm">
|
||||||
|
<Logo size="md" />
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-semibold text-gray-800">{roomInfo.room_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">ID: {roomInfo.meeting_id}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="glass rounded-3xl p-10 shadow-xl max-w-xl w-full animate-slide-up">
|
||||||
|
{!validatorBadge ? (
|
||||||
|
// Attesa badge validatore
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-focolare-blue/10 mb-6">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 text-focolare-blue"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-4">
|
||||||
|
Accesso Varco
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 mb-8">
|
||||||
|
Passa il badge del <span className="font-semibold text-focolare-blue">Validatore</span> per iniziare
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="py-8 px-6 bg-focolare-blue/5 rounded-2xl border-2 border-dashed border-focolare-blue/30">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-focolare-blue animate-pulse"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-xl text-focolare-blue font-medium">
|
||||||
|
In attesa del badge...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Form password
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success/10 mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 text-success"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||||
|
Badge Riconosciuto
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Badge: <span className="font-mono font-semibold">{validatorBadge}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="password"
|
||||||
|
label="Password Validatore"
|
||||||
|
placeholder="Inserisci la password..."
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
error={error}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Conferma
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer with RFID Status */}
|
||||||
|
<footer className="p-4 border-t bg-white/50 flex items-center justify-center">
|
||||||
|
<RFIDStatus state={rfidState} buffer={rfidBuffer} />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ValidatorLoginScreen;
|
||||||
6
frontend/src/screens/index.ts
Normal file
6
frontend/src/screens/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Screens barrel export
|
||||||
|
export { LoadingScreen } from './LoadingScreen';
|
||||||
|
export { ValidatorLoginScreen } from './ValidatorLoginScreen';
|
||||||
|
export { ActiveGateScreen } from './ActiveGateScreen';
|
||||||
|
export { SuccessModal } from './SuccessModal';
|
||||||
|
export { ErrorModal } from './ErrorModal';
|
||||||
135
frontend/src/services/api.ts
Normal file
135
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Focolari Voting System - API Service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RoomInfo,
|
||||||
|
User,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
EntryRequest,
|
||||||
|
EntryResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for API errors
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public statusCode: number,
|
||||||
|
public detail?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic fetch wrapper with error handling
|
||||||
|
*/
|
||||||
|
async function apiFetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new ApiError(
|
||||||
|
errorData.detail || `HTTP Error ${response.status}`,
|
||||||
|
response.status,
|
||||||
|
errorData.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
'Errore di connessione al server',
|
||||||
|
0,
|
||||||
|
'Verifica che il server sia attivo'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API Endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /info-room
|
||||||
|
* Ottiene le informazioni sulla sala e la riunione
|
||||||
|
*/
|
||||||
|
export async function getRoomInfo(): Promise<RoomInfo> {
|
||||||
|
return apiFetch<RoomInfo>('/info-room');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /login-validate
|
||||||
|
* Autentica il validatore con badge e password
|
||||||
|
*/
|
||||||
|
export async function loginValidator(
|
||||||
|
badge: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const payload: LoginRequest = { badge, password };
|
||||||
|
return apiFetch<LoginResponse>('/login-validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /anagrafica/{badge_code}
|
||||||
|
* Ottiene i dati anagrafici di un utente tramite badge
|
||||||
|
*/
|
||||||
|
export async function getUserByBadge(badgeCode: string): Promise<User> {
|
||||||
|
return apiFetch<User>(`/anagrafica/${encodeURIComponent(badgeCode)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /entry-request
|
||||||
|
* Registra l'ingresso di un utente
|
||||||
|
*/
|
||||||
|
export async function requestEntry(
|
||||||
|
userBadge: string,
|
||||||
|
validatorPassword: string
|
||||||
|
): Promise<EntryResponse> {
|
||||||
|
const payload: EntryRequest = {
|
||||||
|
user_badge: userBadge,
|
||||||
|
validator_password: validatorPassword,
|
||||||
|
};
|
||||||
|
return apiFetch<EntryResponse>('/entry-request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the API server is reachable
|
||||||
|
*/
|
||||||
|
export async function checkServerHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/`);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
frontend/src/types/index.ts
Normal file
101
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
}
|
||||||
|
variant?: 'success' | 'error' | 'info';
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
export interface ModalProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
paused?: boolean;
|
||||||
|
onExpire: () => void;
|
||||||
|
seconds: number;
|
||||||
|
export interface TimerProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
showWarning?: boolean;
|
||||||
|
user: User;
|
||||||
|
export interface UserCardProps {
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Component Props Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
}
|
||||||
|
timestamp: number;
|
||||||
|
code: string;
|
||||||
|
export interface RFIDScanResult {
|
||||||
|
|
||||||
|
export type RFIDScannerState = 'idle' | 'scanning';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RFID Scanner Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
}
|
||||||
|
expiresAt: number;
|
||||||
|
loginTime: number;
|
||||||
|
token: string;
|
||||||
|
badge: string;
|
||||||
|
export interface ValidatorSession {
|
||||||
|
|
||||||
|
| 'entry-error';
|
||||||
|
| 'entry-success'
|
||||||
|
| 'showing-user'
|
||||||
|
| 'gate-active'
|
||||||
|
| 'validator-password'
|
||||||
|
| 'waiting-validator'
|
||||||
|
| 'loading'
|
||||||
|
export type AppState =
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Application State Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
}
|
||||||
|
validator_password: string;
|
||||||
|
user_badge: string;
|
||||||
|
export interface EntryRequest {
|
||||||
|
|
||||||
|
}
|
||||||
|
password: string;
|
||||||
|
badge: string;
|
||||||
|
export interface LoginRequest {
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Request Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
}
|
||||||
|
welcome_message?: string;
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
export interface EntryResponse {
|
||||||
|
|
||||||
|
}
|
||||||
|
token?: string;
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
export interface LoginResponse {
|
||||||
|
|
||||||
|
}
|
||||||
|
warning?: string;
|
||||||
|
ammesso: boolean;
|
||||||
|
ruolo: 'Tecnico' | 'Votante' | 'Ospite';
|
||||||
|
url_foto: string;
|
||||||
|
cognome: string;
|
||||||
|
nome: string;
|
||||||
|
badge_code: string;
|
||||||
|
export interface User {
|
||||||
|
|
||||||
|
}
|
||||||
|
meeting_id: string;
|
||||||
|
room_name: string;
|
||||||
|
export interface RoomInfo {
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API Response Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
*/
|
||||||
|
* Focolari Voting System - TypeScript Types
|
||||||
19
frontend/tailwind.config.js
Normal file
19
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
focolari: {
|
||||||
|
blue: '#0072CE',
|
||||||
|
orange: '#F5A623',
|
||||||
|
yellow: '#FFD700',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
8
frontend/vite.config.ts
Normal file
8
frontend/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user