From e68f299febf492e56e47fddd27eae56d70d6ba05 Mon Sep 17 00:00:00 2001 From: alfy Date: Sat, 17 Jan 2026 20:06:50 +0100 Subject: [PATCH] feat: Controllo accessi RFID completo con gestione sessioni MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aggiunto supporto multi-pattern RFID (US/IT layout) - Implementata invalidazione sessioni al restart del server - Schermata "badge non trovato" con countdown 30s - Notifica quando badge validatore passato senza utente - Database aggiornato con badge reali di test - Layout ottimizzato per tablet orizzontale - Banner NumLock per desktop - Toggle visibilitΓ  password - Carosello benvenuto multilingua (10 lingue) - Pagina debug RFID (/debug) --- README.md | 47 +- ai-prompts/00-welcome-agent.md | 272 +++---- ai-prompts/01-backend-plan.md | 118 ++- ai-prompts/02-frontend-plan.md | 273 ++----- backend-mock/.gitignore | 2 + backend-mock/Pipfile | 8 +- backend-mock/api/__init__.py | 7 + backend-mock/api/routes.py | 153 ++++ backend-mock/data/users_default.json | 33 + backend-mock/data/users_test.json | 33 + backend-mock/main.py | 416 ++++------- backend-mock/schemas/__init__.py | 21 + backend-mock/schemas/models.py | 50 ++ dev.sh | 258 +++++++ frontend/README.md | 15 +- frontend/eslint.config.js | 28 +- frontend/index.html | 22 +- frontend/package-lock.json | 60 +- frontend/package.json | 3 +- frontend/public/favicon.jpg | Bin 0 -> 11077 bytes frontend/src/App.tsx | 706 +++++++++--------- frontend/src/components/Button.tsx | 122 +-- frontend/src/components/CountdownTimer.tsx | 156 ++-- frontend/src/components/Input.tsx | 99 ++- frontend/src/components/Logo.tsx | 52 +- frontend/src/components/Modal.tsx | 124 +-- frontend/src/components/NumLockBanner.tsx | 132 ++++ frontend/src/components/RFIDStatus.tsx | 36 +- frontend/src/components/UserCard.tsx | 146 ++-- frontend/src/components/WelcomeCarousel.tsx | 100 +++ frontend/src/components/index.ts | 16 +- frontend/src/hooks/useRFIDScanner.ts | 480 +++++++----- frontend/src/index.css | 150 ++-- frontend/src/main.tsx | 21 +- frontend/src/screens/ActiveGateScreen.tsx | 458 +++++++----- frontend/src/screens/DebugScreen.tsx | 208 ++++++ frontend/src/screens/ErrorModal.tsx | 117 +-- frontend/src/screens/LoadingScreen.tsx | 214 ++++-- frontend/src/screens/SuccessModal.tsx | 126 ++-- frontend/src/screens/ValidatorLoginScreen.tsx | 321 ++++---- frontend/src/screens/index.ts | 11 +- frontend/src/services/api.ts | 160 ++-- frontend/src/types/index.ts | 192 ++--- frontend/tailwind.config.js | 30 +- frontend/tsconfig.app.json | 16 +- frontend/tsconfig.json | 8 +- frontend/tsconfig.node.json | 14 +- frontend/vite.config.ts | 36 +- 48 files changed, 3625 insertions(+), 2445 deletions(-) create mode 100644 backend-mock/api/__init__.py create mode 100644 backend-mock/api/routes.py create mode 100644 backend-mock/data/users_default.json create mode 100644 backend-mock/data/users_test.json create mode 100644 backend-mock/schemas/__init__.py create mode 100644 backend-mock/schemas/models.py create mode 100755 dev.sh create mode 100644 frontend/public/favicon.jpg create mode 100644 frontend/src/components/NumLockBanner.tsx create mode 100644 frontend/src/components/WelcomeCarousel.tsx create mode 100644 frontend/src/screens/DebugScreen.tsx diff --git a/README.md b/README.md index bf5effe..65edfa6 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,58 @@ Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focola ## πŸ“– Descrizione -Applicazione web ottimizzata per tablet che gestisce i varchi d'ingresso alle sale votazione. Il sistema utilizza lettori RFID USB (che emulano tastiera) per identificare validatori e partecipanti. +Applicazione web ottimizzata per tablet che gestisce i varchi d'ingresso alle sale votazione. Il sistema utilizza +lettori RFID USB (che emulano tastiera) per identificare validatori e partecipanti. ## πŸ—οΈ Struttura Progetto ``` VotoFocolari/ +β”œβ”€β”€ dev.sh # Script sviluppo (install, dev, server, build, ...) β”œβ”€β”€ ai-prompts/ # Documentazione sviluppo e prompt β”œβ”€β”€ backend-mock/ # API mock in Python FastAPI +β”‚ └── static/ # Frontend buildato (generato) └── frontend/ # App React + TypeScript + Tailwind ``` ## πŸš€ Quick Start -### Backend +### Setup Iniziale + ```bash -cd backend-mock -pipenv install -pipenv run start -# Server: http://localhost:8000 -# Docs API: http://localhost:8000/docs +./dev.sh install ``` -### Frontend +### Sviluppo (hot reload) + ```bash -cd frontend -npm install -npm run dev -# App: http://localhost:5173 +./dev.sh dev +# Backend API: http://localhost:8000 +# Frontend Dev: http://localhost:5173 +``` + +### Produzione Locale + +```bash +./dev.sh server +# App completa: http://localhost:8000 +``` + +### Altri Comandi + +```bash +./dev.sh build # Solo build frontend +./dev.sh backend # Solo backend (API) +./dev.sh frontend # Solo frontend dev +./dev.sh shell # Shell pipenv backend +./dev.sh clean # Pulisce build e cache +./dev.sh help # Mostra tutti i comandi ``` ## πŸ“š Documentazione Per dettagli tecnici, consulta la cartella `ai-prompts/`: + - `00-welcome-agent.md` - Panoramica progetto - `01-backend-plan.md` - Piano sviluppo backend - `02-frontend-plan.md` - Piano sviluppo frontend @@ -46,6 +65,10 @@ Per dettagli tecnici, consulta la cartella `ai-prompts/`: - **Badge Validatore:** `999999` - **Password:** `focolari` +## πŸ” Debug + +Accedi a `/debug` per diagnostica RFID in tempo reale. + ## πŸ“„ Licenza Progetto privato - Movimento dei Focolari diff --git a/ai-prompts/00-welcome-agent.md b/ai-prompts/00-welcome-agent.md index fa61235..a82101e 100644 --- a/ai-prompts/00-welcome-agent.md +++ b/ai-prompts/00-welcome-agent.md @@ -1,206 +1,150 @@ -# 🎯 Focolari Voting System - Guida Agente +# πŸ—³οΈ Focolari Voting System - Welcome Agent ## Panoramica Progetto -**Nome:** Sistema Controllo Accessi "Focolari Voting System" -**Committente:** Movimento dei Focolari -**Scopo:** Gestione dei varchi di accesso per le assemblee di voto del Movimento. - -### Contesto d'Uso - -Il sistema funziona su **tablet Android** (via browser Chrome) posizionati ai varchi d'ingresso delle sale votazione. Ogni tablet Γ¨ collegato a un **lettore RFID USB** che emula tastiera. +Sistema di controllo accessi per le assemblee di voto del **Movimento dei Focolari**. +Applicazione web ottimizzata per **tablet in orizzontale** che gestisce i varchi d'ingresso alle sale votazione tramite +**lettori RFID USB** (emulano tastiera). --- -## πŸ—οΈ Architettura +## Stack Tecnologico + +### Backend Mock + +- **Python 3.11+** con **FastAPI** +- **Pydantic** per validazione dati +- **Uvicorn** come server ASGI +- **Pipenv** per gestione dipendenze + +### Frontend + +- **React 18** + **TypeScript** +- **Vite** come build tool +- **Tailwind CSS 4** per styling +- **React Router** per navigazione + +--- + +## Struttura Progetto ``` VotoFocolari/ -β”œβ”€β”€ ai-prompts/ # Documentazione e piani di sviluppo -β”œβ”€β”€ backend-mock/ # Python FastAPI (server mock) -β”‚ β”œβ”€β”€ 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 +β”œβ”€β”€ dev.sh # Script di sviluppo (install, dev, server, ...) +β”œβ”€β”€ README.md +β”œβ”€β”€ ai-prompts/ # Documentazione sviluppo +β”‚ β”œβ”€β”€ 00-welcome-agent.md # Questo file +β”‚ β”œβ”€β”€ 01-backend-plan.md # Piano backend +β”‚ └── 02-frontend-plan.md # Piano frontend +β”œβ”€β”€ backend-mock/ +β”‚ β”œβ”€β”€ main.py # Entry point con argparse +β”‚ β”œβ”€β”€ Pipfile # Dipendenze Python +β”‚ β”œβ”€β”€ api/routes.py # Endpoint API +β”‚ β”œβ”€β”€ schemas/models.py # Modelli Pydantic +β”‚ └── data/ +β”‚ β”œβ”€β”€ users_default.json +β”‚ └── users_test.json +└── frontend/ + β”œβ”€β”€ package.json + β”œβ”€β”€ vite.config.ts └── src/ β”œβ”€β”€ App.tsx # State machine principale - β”œβ”€β”€ hooks/ # Custom hooks (RFID scanner) + β”œβ”€β”€ hooks/ # useRFIDScanner β”œβ”€β”€ components/ # UI components - β”œβ”€β”€ screens/ # Schermate complete - β”œβ”€β”€ services/ # API layer - β”œβ”€β”€ types/ # TypeScript definitions - └── tests/ # Test automatici use case + β”œβ”€β”€ screens/ # Schermate + β”œβ”€β”€ services/ # API client + └── types/ # TypeScript types ``` --- -## πŸ”§ Stack Tecnologico +## FunzionalitΓ  Principali -### Backend Mock -- **Python 3.10+** -- **FastAPI** - Framework web asincrono -- **Uvicorn** - ASGI server -- **Pydantic** - Validazione dati -- **pipenv** - Gestione ambiente virtuale -- **argparse** - Parametri CLI (porta, dataset) +### Flusso Utente -### Frontend -- **React 19** - UI Library -- **TypeScript 5.9** - Type safety -- **Vite 7** - Build tool -- **Tailwind CSS 4** - Styling utility-first -- **React Router** - Routing (/, /debug) -- **Vitest** - Testing framework -- **Design:** Ottimizzato per touch tablet +1. **Login Validatore**: Passa badge + inserisci password β†’ Sessione 30 min +2. **Attesa Partecipante**: Schermata grande "Passa il badge" +3. **Visualizzazione Utente**: Card con foto, nome, ruolo, stato ammissione +4. **Conferma Ingresso**: Validatore ripassa il badge β†’ Carosello benvenuto multilingua + +### Gestione RFID + +- **Multi-pattern**: Supporta layout tastiera US (`;?`) e IT (`Γ²_`) +- **Timeout 2.5s**: Per scansioni accidentali +- **ESC annulla**: Scansione in corso +- **Enter handling**: Gestito automaticamente + +### Sicurezza Sessioni + +- Sessione salvata in localStorage +- **Invalidazione automatica** se il server riparte +- Timeout 30 minuti --- -## 🎨 Design System +## Comandi Rapidi -| Colore | Hex | Uso | -|--------|-----|-----| -| Blu Istituzionale | `#0072CE` | Colore primario, brand | -| Arancio Accento | `#F5A623` | Azioni secondarie | -| Giallo | `#FFD700` | Evidenze | -| Verde Success | `#22C55E` | Conferme, ammesso | -| Rosso Error | `#EF4444` | Errori, non ammesso | - ---- - -## πŸ“Ÿ Logica RFID (CRITICA) - -Il lettore RFID simula una tastiera. **Non possiamo distinguerlo dalla digitazione umana** in base alla velocitΓ . - -### Protocollo -- **Formato:** `` -- **Esempio US:** `;00012345?` -- **Esempio IT:** `Γ²00012345_` - -### Pattern Supportati -```typescript -const VALID_PATTERNS = [ - { start: ';', end: '?' }, // Layout US - { start: 'Γ²', end: '_' }, // Layout IT -]; -``` - -### Strategia -1. Ascolto globale `keydown` -2. Se ricevo un carattere `start` β†’ avvio buffer + timeout (2.5s) -3. Accumulo caratteri nel buffer -4. Se ricevo il corretto `end` β†’ emetto codice pulito -5. Se timeout scade β†’ scarto buffer - ---- - -## πŸ”„ State Machine Applicativa - -``` -LOADING β†’ WAITING_VALIDATOR β†’ [badge validatore] - β†’ VALIDATOR_PASSWORD β†’ [password OK] - β†’ GATE_ACTIVE β†’ [badge partecipante] - β†’ SHOWING_USER β†’ [badge validatore] - β†’ SUCCESS_MODAL (5s, carosello multilingua) β†’ GATE_ACTIVE -``` - ---- - -## 🌍 Messaggi Benvenuto (Frontend) - -Il backend risponde in modo **asettico** (solo `success: true`). -Il frontend mostra un **carosello automatico** di messaggi multilingua: - -- Italiano, English, FranΓ§ais, Deutsch, EspaΓ±ol, PortuguΓͺs, δΈ­ζ–‡, ζ—₯本θͺž - -Scorrimento ogni ~2 secondi, durata modale 5 secondi. - ---- - -## πŸ“ File Chiave - -| File | Descrizione | -|------|-------------| -| `backend-mock/main.py` | Entry point con argparse | -| `backend-mock/api/routes.py` | Definizione endpoint | -| `backend-mock/schemas/models.py` | Modelli Pydantic | -| `backend-mock/data/*.json` | Dataset utenti | -| `frontend/src/hooks/useRFIDScanner.ts` | Cuore del sistema - gestione lettore | -| `frontend/src/App.tsx` | State machine e orchestrazione | -| `frontend/src/components/WelcomeCarousel.tsx` | Carosello multilingua | -| `frontend/src/screens/DebugScreen.tsx` | Diagnostica RFID | -| `frontend/src/tests/` | Test automatici use case | - ---- - -## πŸš€ Quick Start - -### Backend ```bash -cd backend-mock -pipenv install -pipenv run python main.py # Default -pipenv run python main.py -p 9000 # Porta custom -pipenv run python main.py -d data/test.json # Dataset test -``` +# Setup iniziale +./dev.sh install -### Frontend -```bash -cd frontend -npm install -npm run dev # Sviluppo -npm run test # Test suite -npm run test:ui # Test con UI +# Sviluppo (hot reload) +./dev.sh dev +# Frontend: http://localhost:5173 +# Backend: http://localhost:8000 + +# Produzione locale +./dev.sh server +# App completa: http://localhost:8000 + +# Debug RFID +# Vai a http://localhost:8000/debug ``` --- -## πŸ§ͺ Test Automatici +## Badge di Test -Suite di test per validazione use case: +| Badge | Nome | Ruolo | Ammesso | +|--------------|----------------|---------|---------------| +| `0008988288` | Marco Bianchi | Votante | βœ… | +| `0007399575` | Laura Rossi | Votante | βœ… | +| `0000514162` | Giuseppe Verdi | Tecnico | ❌ | +| `0006478281` | - | - | ⚠️ Non nel DB | -| Test | Descrizione | -|------|-------------| -| UC01 | Login validatore | -| UC02 | Accesso partecipante ammesso | -| UC03 | Accesso negato | -| UC04 | Timeout sessione | -| UC05 | Cambio rapido badge | -| UC06 | Pattern RFID multipli | +**Password validatore:** `focolari` --- -## πŸ” Debug +## Dove Trovare Cosa -### Console Browser -Tutti i log sono prefissati: -- `[RFID]` - Eventi lettore badge -- `[FLOW]` - Transizioni stato -- `[API]` - Chiamate HTTP - -### Pagina Debug (`/debug`) -Accesso: logo cliccabile 5 volte. -Mostra in tempo reale: -- Ultimi 20 tasti premuti -- Stato scanner (idle/scanning) -- Buffer corrente -- Pattern attivo +| Cosa cerchi | Dove guardare | +|----------------------------|----------------------------------------| +| Logica flusso applicazione | `frontend/src/App.tsx` | +| Hook lettura RFID | `frontend/src/hooks/useRFIDScanner.ts` | +| Chiamate API | `frontend/src/services/api.ts` | +| Endpoint backend | `backend-mock/api/routes.py` | +| Dati mock utenti | `backend-mock/data/users_default.json` | +| Configurazione Vite | `frontend/vite.config.ts` | --- -## ⚠️ Note Importanti +## Note Importanti -1. **Badge Validatore:** `999999` con password `focolari` -2. **Sessione:** 30 minuti di timeout, salvata in localStorage -3. **Timeout utente:** 60 secondi sulla schermata decisione -4. **Multi-layout:** Il sistema supporta RFID su tastiera US e IT -5. **Backend asettico:** Nessun messaggio multilingua dal server -6. **Git:** Il progetto sfrutta un Repository per versionare e sviluppare, ma solo l'utente puΓ² eseguire comandi git, eccetto se richiesto diversamente, l'agente puΓ² solo chiedere di eseguire o suggerire cosa fare, ma mai prendere iniziative +1. **Layout Tastiera**: Il lettore RFID potrebbe inviare caratteri diversi in base al layout OS. La pagina `/debug` + aiuta a diagnosticare. + +2. **Server Restart**: Quando il server riparte, tutte le sessioni frontend vengono invalidate (controllato via + `server_start_time`). + +3. **Badge Validatore**: Qualsiasi badge puΓ² diventare validatore se la password Γ¨ corretta. Il badge viene memorizzato + nella sessione. + +4. **NumLock**: Su desktop, viene mostrato un banner per ricordare di attivare NumLock. --- -## πŸ“š Documentazione Correlata +## TODO (da concordare con committenti) -- `01-backend-plan.md` - Piano sviluppo backend -- `02-frontend-plan.md` - Piano sviluppo frontend +- [ ] Verificare se il badge validatore debba essere validato anche lato server +- [ ] Test automatici E2E per regression detection diff --git a/ai-prompts/01-backend-plan.md b/ai-prompts/01-backend-plan.md index b778d52..2562236 100644 --- a/ai-prompts/01-backend-plan.md +++ b/ai-prompts/01-backend-plan.md @@ -13,7 +13,6 @@ Struttura modulare con separazione tra API, modelli e dati. backend-mock/ β”œβ”€β”€ main.py # Entry point con argparse β”œβ”€β”€ Pipfile # Dipendenze pipenv -β”œβ”€β”€ requirements.txt # Backup dipendenze β”œβ”€β”€ .gitignore β”œβ”€β”€ api/ β”‚ β”œβ”€β”€ __init__.py @@ -22,7 +21,7 @@ backend-mock/ β”‚ β”œβ”€β”€ __init__.py β”‚ └── models.py # Modelli Pydantic └── data/ - β”œβ”€β”€ users_default.json # Dataset utenti default + β”œβ”€β”€ users_default.json # Dataset utenti default (badge reali) └── users_test.json # Dataset per test ``` @@ -35,36 +34,40 @@ backend-mock/ - [x] Creare cartella `backend-mock/` - [x] Creare `Pipfile` per pipenv - [x] Configurare `.gitignore` per Python -- [ ] Creare struttura cartelle (`api/`, `schemas/`, `data/`) +- [x] Creare struttura cartelle (`api/`, `schemas/`, `data/`) ### 2. Modelli Pydantic (`schemas/models.py`) - [x] `LoginRequest` - badge + password - [x] `EntryRequest` - user_badge + validator_password - [x] `UserResponse` - dati utente + warning opzionale -- [x] `RoomInfoResponse` - nome sala + meeting_id +- [x] `RoomInfoResponse` - nome sala + meeting_id + **server_start_time** - [x] `LoginResponse` - success + message + token - [x] `EntryResponse` - success + message (SENZA welcome_message) -- [ ] **DA FARE:** Spostare modelli in file dedicato +- [x] Spostare modelli in file dedicato ### 3. Dati Mock (`data/*.json`) -- [x] Costanti validatore (badge `999999`, password `focolari`) -- [x] Lista utenti mock (7 utenti con dati realistici) - - [x] Mix di ruoli: Votante, Tecnico, Ospite - - [x] Alcuni con `ammesso: false` per test - - [x] URL foto placeholder (randomuser.me) -- [ ] **DA FARE:** Estrarre dati in file JSON separato -- [ ] **DA FARE:** Creare dataset alternativo per test -- [ ] **DA FARE:** Caricare dati dinamicamente all'avvio +- [x] Password validatore (solo password, badge gestito dal frontend) +- [x] Lista utenti mock con **badge reali**: + - `0008988288` - Marco Bianchi (Votante, ammesso) + - `0007399575` - Laura Rossi (Votante, ammessa) + - `0000514162` - Giuseppe Verdi (Tecnico, NON ammesso) + - `0006478281` - **NON nel DB** (per test "non trovato") +- [x] Estrarre dati in file JSON separato +- [x] Creare dataset alternativo per test (`users_test.json`) +- [x] Caricare dati dinamicamente all'avvio -**Nota:** I messaggi di benvenuto multilingua sono stati **RIMOSSI** dal backend. -Il frontend gestirΓ  autonomamente la visualizzazione internazionale con carosello. +**Nota:** I messaggi di benvenuto multilingua sono gestiti dal frontend con carosello. + +**TODO (da concordare con committenti):** + +- Valutare se `login-validate` debba ricevere e verificare anche il badge del validatore ### 4. Routes API (`api/routes.py`) -- [x] `GET /info-room` - info sala -- [x] `POST /login-validate` - autenticazione validatore +- [x] `GET /info-room` - info sala + **server_start_time** per invalidare sessioni +- [x] `POST /login-validate` - verifica solo password validatore - [x] `GET /anagrafica/{badge_code}` - ricerca utente - [x] Pulizia caratteri sentinel dal badge - [x] Confronto con e senza zeri iniziali @@ -73,27 +76,25 @@ Il frontend gestirΓ  autonomamente la visualizzazione internazionale con carosel - [x] Verifica password validatore - [x] Verifica utente ammesso - [x] Risposta asettica (solo success + message) -- [ ] **DA FARE:** Spostare routes in file dedicato +- [x] Spostare routes in file dedicato ### 5. Entry Point (`main.py`) - [x] Blocco `if __name__ == "__main__"` - [x] Configurazione uvicorn (host, port) - [x] Messaggi console all'avvio -- [ ] **DA FARE:** Implementare argparse con opzioni: - - `--port` / `-p` : porta server (default: 8000) - - `--data` / `-d` : path file JSON dati (default: `data/users_default.json`) - - `--host` : host binding (default: `0.0.0.0`) -- [ ] **DA FARE:** Caricamento dinamico dati da JSON -- [ ] **DA FARE:** Import routes da modulo `api` +- [x] 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`) +- [x] Caricamento dinamico dati da JSON +- [x] Import routes da modulo `api` -### 6. Struttura Base FastAPI +### 6. Invalidazione Sessioni -- [x] Import FastAPI e dipendenze -- [x] Configurazione app con titolo e descrizione -- [x] Middleware CORS (allow all origins) -- [x] Endpoint root `/` per health check -- [ ] **DA FARE:** Refactor in struttura modulare +- [x] `SERVER_START_TIME` generato all'avvio del server +- [x] Restituito in `/info-room` per permettere al frontend di invalidare sessioni vecchie +- [x] Se il server riparte, tutte le sessioni frontend vengono invalidate --- @@ -101,19 +102,16 @@ Il frontend gestirΓ  autonomamente la visualizzazione internazionale con carosel ```json { - "validator": { - "badge": "999999", - "password": "focolari" - }, + "validator_password": "focolari", "room": { "room_name": "Sala Assemblea", "meeting_id": "VOT-2024" }, "users": [ { - "badge_code": "000001", - "nome": "Maria", - "cognome": "Rossi", + "badge_code": "0008988288", + "nome": "Marco", + "cognome": "Bianchi", "url_foto": "https://...", "ruolo": "Votante", "ammesso": true @@ -126,25 +124,22 @@ Il frontend gestirΓ  autonomamente la visualizzazione internazionale con carosel ## Comandi Esecuzione -### Avvio Standard +### Via Script (consigliato) + +```bash +./dev.sh server # Avvia server con frontend +./dev.sh server -p 9000 # Porta custom +./dev.sh backend # Solo API, no frontend +``` + +### Manuale + ```bash cd backend-mock pipenv install pipenv run python main.py ``` -### Avvio con Parametri Custom -```bash -# Porta diversa -pipenv run python main.py --port 9000 - -# Dataset test -pipenv run python main.py --data data/users_test.json - -# Combinato -pipenv run python main.py -p 9000 -d data/users_test.json -``` - --- ## Test Rapidi @@ -153,29 +148,28 @@ pipenv run python main.py -p 9000 -d data/users_test.json # Health check curl http://localhost:8000/ -# Info sala +# Info sala (include server_start_time) curl http://localhost:8000/info-room -# Ricerca utente -curl http://localhost:8000/anagrafica/000001 +# Ricerca utente reale +curl http://localhost:8000/anagrafica/0008988288 + +# Badge non trovato +curl http://localhost:8000/anagrafica/0006478281 # Login validatore curl -X POST http://localhost:8000/login-validate \ -H "Content-Type: application/json" \ - -d '{"badge": "999999", "password": "focolari"}' + -d '{"badge": "qualsiasi", "password": "focolari"}' -# Richiesta ingresso (risposta asettica) +# Richiesta ingresso curl -X POST http://localhost:8000/entry-request \ -H "Content-Type: application/json" \ - -d '{"user_badge": "000001", "validator_password": "focolari"}' + -d '{"user_badge": "0008988288", "validator_password": "focolari"}' ``` --- -## Note Implementative +## βœ… BACKEND COMPLETATO -- Gli endpoint puliscono automaticamente i caratteri `;`, `?`, `Γ²`, `_` dai badge -- Il confronto badge avviene sia con che senza zeri iniziali -- Le risposte seguono lo standard HTTP (200 OK, 401 Unauthorized, 404 Not Found, 403 Forbidden) -- La documentazione OpenAPI Γ¨ auto-generata su `/docs` -- **Risposta `/entry-request`:** JSON asettico `{ success: true, message: "..." }` senza messaggi multilingua +Tutti i task sono stati implementati e testati. diff --git a/ai-prompts/02-frontend-plan.md b/ai-prompts/02-frontend-plan.md index 0106ceb..1b441bd 100644 --- a/ai-prompts/02-frontend-plan.md +++ b/ai-prompts/02-frontend-plan.md @@ -3,7 +3,7 @@ ## Obiettivo Applicazione React per tablet che gestisce il flusso di accesso ai varchi votazione, con lettura badge RFID. -Include sistema di test automatici per validazione e regression detection. +Ottimizzata per tablet in orizzontale. --- @@ -14,8 +14,11 @@ Include sistema di test automatici per validazione e regression detection. - [x] Inizializzare Vite + React + TypeScript - [x] Installare Tailwind CSS 4 - [x] Configurare `tsconfig.json` -- [x] Configurare `vite.config.ts` +- [x] Configurare `vite.config.ts` con proxy API - [x] Configurare `.gitignore` +- [x] Path relativi per build (base: './') +- [x] Output build in `frontend/dist/` +- [x] Favicon con logo Focolari ### 2. Design System @@ -29,15 +32,14 @@ Include sistema di test automatici per validazione e regression detection. ### 3. Tipi TypeScript (`types/index.ts`) -- [x] `RoomInfo` - info sala +- [x] `RoomInfo` - info sala + **server_start_time** - [x] `User` - dati utente - [x] `LoginRequest/Response` - [x] `EntryRequest/Response` (SENZA welcome_message) - [x] `AppState` - stati applicazione -- [x] `ValidatorSession` - sessione validatore +- [x] `ValidatorSession` - sessione validatore + **serverStartTime** - [x] `RFIDScannerState` - stato scanner - [x] `RFIDScanResult` - risultato scan -- [ ] **DA FARE:** Aggiornare `EntryResponse` rimuovendo `welcome_message` ### 4. API Service (`services/api.ts`) @@ -47,259 +49,90 @@ Include sistema di test automatici per validazione e regression detection. - [x] `loginValidator()` - POST /login-validate - [x] `getUserByBadge()` - GET /anagrafica/{badge} - [x] `requestEntry()` - POST /entry-request -- [ ] **DA FARE:** Logging con prefisso `[API]` +- [x] Logging con prefisso `[API]` +- [x] Path relativi (proxy Vite in dev, stesso server in prod) ### 5. Hook RFID Scanner (`hooks/useRFIDScanner.ts`) -#### Implementazione Base (v1) - -- [x] Listener `keydown` globale -- [x] Stati: idle β†’ scanning β†’ idle -- [x] Singolo pattern (`;` / `?`) -- [x] Timeout sicurezza (3s) -- [x] `preventDefault` durante scan -- [x] Callback `onScan`, `onTimeout`, `onScanStart` - -#### ⚠️ AGGIORNAMENTO RICHIESTO (v2) - Multi-Pattern - -- [ ] Supporto pattern multipli (US, IT, altri) - ```typescript - const VALID_PATTERNS = [ - { start: ';', end: '?' }, // Layout US - { start: 'Γ²', end: '_' }, // Layout IT - ]; - ``` -- [ ] Rilevamento automatico pattern in uso -- [ ] Memorizzazione pattern attivo durante scan -- [ ] Validazione `end` solo per pattern corretto -- [ ] Logging avanzato con prefisso `[RFID]` -- [ ] Esportare info pattern attivo per debug page +- [x] Supporto pattern multipli (US: `;?`, IT: `Γ²_`) +- [x] Rilevamento automatico pattern in uso +- [x] Gestione Enter post-completamento +- [x] Timeout 2.5s per scansioni accidentali +- [x] ESC annulla scansione in corso +- [x] Logging avanzato con prefisso `[RFID]` +- [x] Export `keyLog` per debug ### 6. Componenti UI (`components/`) - [x] `Logo.tsx` - logo Focolari - [x] `Button.tsx` - varianti primary/secondary/danger -- [x] `Input.tsx` - campo input styled +- [x] `Input.tsx` - campo input styled + **toggle password visibility** - [x] `Modal.tsx` - modale base - [x] `RFIDStatus.tsx` - indicatore stato scanner - [x] `UserCard.tsx` - card utente con foto e ruolo - [x] `CountdownTimer.tsx` - timer con progress bar -- [x] `index.ts` - barrel export -- [ ] **DA FARE:** `WelcomeCarousel.tsx` - carosello messaggi multilingua +- [x] `WelcomeCarousel.tsx` - carosello messaggi multilingua +- [x] `NumLockBanner.tsx` - avviso NumLock per desktop ### 7. Schermate (`screens/`) -- [x] `LoadingScreen.tsx` - caricamento iniziale -- [x] `ValidatorLoginScreen.tsx` - attesa badge + password -- [x] `ActiveGateScreen.tsx` - varco attivo + scheda utente -- [x] `SuccessModal.tsx` - conferma ingresso fullscreen +- [x] `LoadingScreen.tsx` - caricamento iniziale + ping automatico +- [x] `ValidatorLoginScreen.tsx` - attesa badge + password + NumLockBanner +- [x] `ActiveGateScreen.tsx` - varco attivo: + - [x] Card utente (layout largo per tablet) + - [x] **Schermata "badge non trovato"** con countdown 30s + - [x] **Notifica badge validatore ignorato** + - [x] NumLockBanner +- [x] `SuccessModal.tsx` - conferma ingresso con carosello - [x] `ErrorModal.tsx` - errore fullscreen -- [x] `index.ts` - barrel export -- [ ] **DA FARE:** `DebugScreen.tsx` - pagina diagnostica RFID +- [x] `DebugScreen.tsx` - pagina diagnostica RFID ### 8. State Machine (`App.tsx`) - [x] Stati applicazione gestiti - [x] Integrazione `useRFIDScanner` - [x] Gestione sessione validatore (localStorage) +- [x] **Qualsiasi badge puΓ² essere validatore** (verificato con password) +- [x] Password salvata in sessione per conferme ingresso +- [x] **Invalidazione sessione se server riparte** (serverStartTime) - [x] Timeout sessione 30 minuti - [x] Timeout utente 60 secondi +- [x] **Timeout badge non trovato 30 secondi** - [x] Cambio rapido badge partecipante -- [x] Conferma con badge validatore -- [ ] **DA FARE:** Logging transizioni con prefisso `[FLOW]` +- [x] Conferma con badge validatore (quello della sessione) +- [x] **Notifica se badge validatore rippassato senza utente** +- [x] Logging transizioni con prefisso `[FLOW]` ### 9. Modale Successo - Carosello Internazionale -#### ⚠️ MODIFICA RICHIESTA - -Il backend **NON** restituisce piΓΉ messaggi multilingua. -Il frontend gestisce autonomamente la visualizzazione con un **carosello automatico**. - -**Specifiche:** -- [ ] Creare componente `WelcomeCarousel.tsx` -- [ ] Lista messaggi di benvenuto in diverse lingue: - ```typescript - const WELCOME_MESSAGES = [ - { lang: 'it', text: 'Benvenuto!' }, - { lang: 'en', text: 'Welcome!' }, - { lang: 'fr', text: 'Bienvenue!' }, - { lang: 'de', text: 'Willkommen!' }, - { lang: 'es', text: 'Bienvenido!' }, - { lang: 'pt', text: 'Bem-vindo!' }, - { lang: 'zh', text: '欒迎!' }, - { lang: 'ja', text: 'γ‚ˆγ†γ“γ!' }, - ]; - ``` -- [ ] Scorrimento automatico (es. ogni 2 secondi) -- [ ] Animazione transizione fluida (fade o slide) -- [ ] Modale fullscreen verde con carosello al centro -- [ ] Durata totale modale: 5 secondi +- [x] Componente `WelcomeCarousel.tsx` +- [x] 10 lingue supportate +- [x] Scorrimento automatico ogni 800ms +- [x] Modale fullscreen verde +- [x] Durata totale: 5 secondi ### 10. Debug & Diagnostica -#### ⚠️ DA IMPLEMENTARE - -- [ ] Pagina `/debug` dedicata - - [ ] Log ultimi 20 tasti premuti (key + code) - - [ ] Stato scanner real-time (idle/scanning) - - [ ] Buffer corrente - - [ ] Pattern attivo (US/IT/...) - - [ ] Ultimo codice rilevato - - [ ] Timestamp eventi -- [ ] Link/pulsante nascosto per accesso debug (es. logo cliccabile 5 volte) -- [ ] Logging console strutturato: - - [ ] `[RFID]` - eventi scanner - - [ ] `[FLOW]` - transizioni stato - - [ ] `[API]` - chiamate HTTP +- [x] Pagina `/debug` dedicata +- [x] Logging console strutturato `[RFID]`, `[FLOW]`, `[API]` ### 11. Routing -- [ ] Installare React Router -- [ ] Route principale `/` -- [ ] Route debug `/debug` - -### 12. Test Automatici (E2E / Use Case Validation) - -#### ⚠️ NUOVA SEZIONE - -Sistema di test per validazione formale dei flussi e regression detection. - -**Struttura:** -``` -frontend/ -β”œβ”€β”€ src/ -β”‚ └── tests/ -β”‚ β”œβ”€β”€ usecase/ -β”‚ β”‚ β”œβ”€β”€ UC01_ValidatorLogin.test.ts -β”‚ β”‚ β”œβ”€β”€ UC02_ParticipantAccess.test.ts -β”‚ β”‚ β”œβ”€β”€ UC03_DeniedAccess.test.ts -β”‚ β”‚ β”œβ”€β”€ UC04_SessionTimeout.test.ts -β”‚ β”‚ β”œβ”€β”€ UC05_QuickBadgeSwitch.test.ts -β”‚ β”‚ └── UC06_RFIDMultiPattern.test.ts -β”‚ └── helpers/ -β”‚ β”œβ”€β”€ mockRFID.ts # Simulatore eventi tastiera RFID -β”‚ └── testUtils.ts # Utility comuni -``` - -**Use Case da Testare:** - -- [ ] **UC01 - Login Validatore** - - Simula badge validatore (`;999999?`) - - Inserisce password corretta - - Verifica transizione a stato `gate-active` - - Verifica sessione salvata in localStorage - -- [ ] **UC02 - Accesso Partecipante Ammesso** - - Da stato `gate-active` - - Simula badge partecipante ammesso - - Verifica caricamento dati utente - - Simula badge validatore per conferma - - Verifica modale successo con carosello - - Verifica ritorno a `gate-active` - -- [ ] **UC03 - Accesso Negato** - - Simula badge partecipante NON ammesso - - Verifica visualizzazione warning rosso - - Verifica che conferma validatore sia bloccata - - Verifica pulsante annulla funzionante - -- [ ] **UC04 - Timeout Sessione** - - Verifica scadenza sessione 30 minuti - - Verifica redirect a login validatore - - Verifica pulizia localStorage - -- [ ] **UC05 - Cambio Rapido Badge** - - Con utente a schermo - - Simula nuovo badge partecipante - - Verifica sostituzione immediata dati - -- [ ] **UC06 - Pattern RFID Multipli** - - Testa pattern US (`;` / `?`) - - Testa pattern IT (`Γ²` / `_`) - - Verifica stesso risultato finale - -**Dipendenze Test:** -```bash -npm install -D vitest @testing-library/react @testing-library/user-event jsdom -``` - -**Script npm:** -```json -{ - "scripts": { - "test": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage" - } -} -``` +- [x] React Router +- [x] Route principale `/` +- [x] Route debug `/debug` --- -## Correzioni Necessarie +## Badge di Test -### Hook RFID - Da Aggiornare - -Il file `useRFIDScanner.ts` attualmente supporta **solo** il pattern US (`;` / `?`). - -**Modifiche richieste:** - -1. Aggiungere costante `VALID_PATTERNS` con pattern multipli -2. Modificare logica `handleKeyDown` per: - - Riconoscere qualsiasi `start` sentinel - - Memorizzare quale pattern Γ¨ in uso - - Validare solo con l'`end` corrispondente -3. Aggiungere stato `activePattern` per debug -4. Migliorare logging - -### Success Modal - Da Aggiornare - -Attualmente usa `welcome_message` dal backend. - -**Modifiche richieste:** - -1. Rimuovere dipendenza da `welcome_message` API -2. Implementare `WelcomeCarousel` con messaggi hardcoded -3. Carosello auto-scroll ogni 2 secondi -4. Animazioni fluide tra messaggi - -### Logging Console - -Attualmente i log usano messaggi generici. Aggiornare tutti i `console.log` con prefissi standardizzati. +| Badge | Nome | Ruolo | Ammesso | +|--------------|----------------|---------|---------------| +| `0008988288` | Marco Bianchi | Votante | βœ… SΓ¬ | +| `0007399575` | Laura Rossi | Votante | βœ… SΓ¬ | +| `0000514162` | Giuseppe Verdi | Tecnico | ❌ No | +| `0006478281` | - | - | ⚠️ Non nel DB | --- -## Dipendenze da Aggiungere - -```bash -# Routing -npm install react-router-dom - -# Testing -npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitest/ui @vitest/coverage-v8 -``` - ---- - -## Comandi Esecuzione - -```bash -cd frontend -npm install -npm run dev # Sviluppo -npm run build # Build produzione -npm run test # Test suite -npm run test:ui # Test con UI interattiva -npm run test:coverage # Coverage report -``` - ---- - -## Note UI/UX - -- Font grandi per leggibilitΓ  tablet -- Pulsanti touch-friendly (min 48px) -- Feedback visivo immediato su azioni -- Animazioni fluide ma non invasive -- Supporto landscape e portrait -- Carosello benvenuto: transizioni smooth, leggibilitΓ  massima +## βœ… FRONTEND COMPLETATO diff --git a/backend-mock/.gitignore b/backend-mock/.gitignore index 9d5584c..bc3e049 100644 --- a/backend-mock/.gitignore +++ b/backend-mock/.gitignore @@ -28,3 +28,5 @@ Thumbs.db # Local environment .env .env.local + + diff --git a/backend-mock/Pipfile b/backend-mock/Pipfile index 35159c7..2266b10 100644 --- a/backend-mock/Pipfile +++ b/backend-mock/Pipfile @@ -4,14 +4,14 @@ verify_ssl = true name = "pypi" [packages] -fastapi = ">=0.109.0" -uvicorn = {extras = ["standard"], version = ">=0.27.0"} -pydantic = ">=2.5.0" +fastapi = "*" +uvicorn = "*" +pydantic = "*" [dev-packages] [requires] -python_version = "3.10" +python_version = "3.14" [scripts] start = "python main.py" diff --git a/backend-mock/api/__init__.py b/backend-mock/api/__init__.py new file mode 100644 index 0000000..9130292 --- /dev/null +++ b/backend-mock/api/__init__.py @@ -0,0 +1,7 @@ +""" +Focolari Voting System - API Package +""" + +from .routes import router, init_data + +__all__ = ["router", "init_data"] diff --git a/backend-mock/api/routes.py b/backend-mock/api/routes.py new file mode 100644 index 0000000..fc11e41 --- /dev/null +++ b/backend-mock/api/routes.py @@ -0,0 +1,153 @@ +""" +Focolari Voting System - API Routes +""" + +import time + +from fastapi import APIRouter, HTTPException + +from schemas import ( + LoginRequest, + EntryRequest, + UserResponse, + RoomInfoResponse, + LoginResponse, + EntryResponse, +) + +router = APIRouter() + +# Timestamp di avvio del server (per invalidare sessioni frontend) +SERVER_START_TIME = int(time.time() * 1000) + +# Dati caricati dinamicamente dal main +_data = { + "validator_password": "", + "room": {"room_name": "", "meeting_id": ""}, + "users": [] +} + +# Caratteri sentinel da pulire (tutti i layout supportati) +SENTINEL_CHARS = [";", "?", "Γ²", "_", "Γ§", "+"] + + +def init_data(data: dict): + """Inizializza i dati caricati dal file JSON""" + global _data + _data = data + + +def clean_badge(badge: str) -> str: + """Rimuove i caratteri sentinel dal badge""" + clean = badge.strip() + for char in SENTINEL_CHARS: + clean = clean.replace(char, "") + return clean + + +def find_user(badge_code: str) -> dict | None: + """Cerca un utente per badge code""" + clean = clean_badge(badge_code) + for user in _data["users"]: + if user["badge_code"] == clean or user["badge_code"].lstrip("0") == clean.lstrip("0"): + return user + return None + + +@router.get("/info-room", response_model=RoomInfoResponse) +async def get_room_info(): + """Restituisce le informazioni sulla sala e la riunione corrente.""" + return RoomInfoResponse( + room_name=_data["room"]["room_name"], + meeting_id=_data["room"]["meeting_id"], + server_start_time=SERVER_START_TIME + ) + + +@router.post("/login-validate", response_model=LoginResponse) +async def login_validate(request: LoginRequest): + """ + Valida la password del validatore. + + Il badge viene passato dal frontend ma attualmente non viene verificato + lato server - serve solo per essere memorizzato nella sessione frontend. + + TODO: Concordare con committenti se il badge debba essere verificato + anche lato server (es. lista badge validatori autorizzati). + """ + if request.password != _data["validator_password"]: + raise HTTPException( + status_code=401, + detail="Password non corretta" + ) + + # Il badge viene restituito per conferma, ma non Γ¨ validato lato server + clean = clean_badge(request.badge) if request.badge else "unknown" + + return LoginResponse( + success=True, + message="Login validatore effettuato con successo", + token=f"focolare-token-{clean}" + ) + + +@router.get("/anagrafica/{badge_code}", response_model=UserResponse) +async def get_user_anagrafica(badge_code: str): + """ + Cerca un utente tramite il suo badge code. + """ + user = find_user(badge_code) + + if not user: + clean = clean_badge(badge_code) + raise HTTPException( + status_code=404, + detail=f"Utente con badge {clean} non trovato nel sistema" + ) + + response = UserResponse( + badge_code=user["badge_code"], + nome=user["nome"], + cognome=user["cognome"], + url_foto=user["url_foto"], + ruolo=user["ruolo"], + ammesso=user["ammesso"] + ) + + if not user["ammesso"]: + response.warning = "ATTENZIONE: Questo utente NON Γ¨ autorizzato all'ingresso!" + + return response + + +@router.post("/entry-request", response_model=EntryResponse) +async def process_entry_request(request: EntryRequest): + """ + Processa una richiesta di ingresso. + Risposta asettica senza messaggi multilingua. + """ + if request.validator_password != _data["validator_password"]: + raise HTTPException( + status_code=401, + detail="Password validatore non corretta" + ) + + user = find_user(request.user_badge) + + if not user: + clean = clean_badge(request.user_badge) + raise HTTPException( + status_code=404, + detail=f"Utente con badge {clean} non trovato" + ) + + if not user["ammesso"]: + raise HTTPException( + status_code=403, + detail=f"L'utente {user['nome']} {user['cognome']} NON Γ¨ autorizzato all'ingresso" + ) + + return EntryResponse( + success=True, + message=f"Ingresso registrato per {user['nome']} {user['cognome']}" + ) diff --git a/backend-mock/data/users_default.json b/backend-mock/data/users_default.json new file mode 100644 index 0000000..4727986 --- /dev/null +++ b/backend-mock/data/users_default.json @@ -0,0 +1,33 @@ +{ + "validator_password": "focolari", + "room": { + "room_name": "Sala Assemblea", + "meeting_id": "VOT-2024" + }, + "users": [ + { + "badge_code": "0008988288", + "nome": "Marco", + "cognome": "Bianchi", + "url_foto": "https://randomuser.me/api/portraits/men/1.jpg", + "ruolo": "Votante", + "ammesso": true + }, + { + "badge_code": "0007399575", + "nome": "Laura", + "cognome": "Rossi", + "url_foto": "https://randomuser.me/api/portraits/women/2.jpg", + "ruolo": "Votante", + "ammesso": true + }, + { + "badge_code": "0000514162", + "nome": "Giuseppe", + "cognome": "Verdi", + "url_foto": "https://randomuser.me/api/portraits/men/3.jpg", + "ruolo": "Tecnico", + "ammesso": false + } + ] +} diff --git a/backend-mock/data/users_test.json b/backend-mock/data/users_test.json new file mode 100644 index 0000000..e8ba7dc --- /dev/null +++ b/backend-mock/data/users_test.json @@ -0,0 +1,33 @@ +{ + "validator_password": "test123", + "room": { + "room_name": "Sala Test", + "meeting_id": "TEST-001" + }, + "users": [ + { + "badge_code": "111111", + "nome": "Test", + "cognome": "Ammesso", + "url_foto": "https://randomuser.me/api/portraits/lego/1.jpg", + "ruolo": "Votante", + "ammesso": true + }, + { + "badge_code": "222222", + "nome": "Test", + "cognome": "NonAmmesso", + "url_foto": "https://randomuser.me/api/portraits/lego/2.jpg", + "ruolo": "Ospite", + "ammesso": false + }, + { + "badge_code": "333333", + "nome": "Test", + "cognome": "Tecnico", + "url_foto": "https://randomuser.me/api/portraits/lego/3.jpg", + "ruolo": "Tecnico", + "ammesso": true + } + ] +} diff --git a/backend-mock/main.py b/backend-mock/main.py index 8e4209d..5b362bb 100644 --- a/backend-mock/main.py +++ b/backend-mock/main.py @@ -1,277 +1,191 @@ """ Focolari Voting System - Backend Mock Sistema di controllo accessi per votazioni del Movimento dei Focolari + +Utilizzo: + python main.py # Default: porta 8000, dati default + python main.py -p 9000 # Porta custom + python main.py -d data/users_test.json # Dataset custom + python main.py -p 9000 -d data/users_test.json """ -import random -from fastapi import FastAPI, HTTPException +import argparse +import json +import sys +from pathlib import Path + +import uvicorn +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import Optional +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles -app = FastAPI( - title="Focolari Voting System API", - description="Backend mock per il sistema di controllo accessi", - version="1.0.0" -) +from api.routes import router, init_data -# 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"} +# Configurazione default +DEFAULT_PORT = 8000 +DEFAULT_HOST = "0.0.0.0" +DEFAULT_DATA = "data/users_default.json" +STATIC_DIR = Path(__file__).parent.parent / "frontend" / "dist" -@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" +def load_data(data_path: str) -> dict: + """Carica i dati dal file JSON""" + path = Path(data_path) + + if not path.is_absolute(): + # Path relativo alla directory del main.py + base_dir = Path(__file__).parent + path = base_dir / path + + if not path.exists(): + print(f"❌ Errore: File dati non trovato: {path}") + sys.exit(1) + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + print(f"πŸ“‚ Dati caricati da: {path}") + print(f" - Password validatore: {'*' * len(data['validator_password'])}") + print(f" - Sala: {data['room']['room_name']}") + print(f" - Utenti: {len(data['users'])}") + return data + except json.JSONDecodeError as e: + print(f"❌ Errore parsing JSON: {e}") + sys.exit(1) + except KeyError as e: + print(f"❌ Errore struttura JSON: chiave mancante {e}") + sys.exit(1) + + +def create_app(data: dict, serve_frontend: bool = True) -> FastAPI: + """Crea e configura l'applicazione FastAPI""" + app = FastAPI( + title="Focolari Voting System API", + description="Backend mock per il sistema di controllo accessi", + version="1.0.0" ) - -@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 + # CORS abilitato per tutti (utile in sviluppo) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) + # Inizializza i dati nelle routes + init_data(data) -@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("?", "") + # Registra le routes API + app.include_router(router) - # 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"] - ) + # Serve frontend statico se la cartella esiste + if serve_frontend and STATIC_DIR.exists(): + print(f"🌐 Frontend statico servito da: {STATIC_DIR}") - # Aggiungi warning se non ammesso - if not user["ammesso"]: - response.warning = "ATTENZIONE: Questo utente NON Γ¨ autorizzato all'ingresso!" + # Serve index.html per la root e tutte le route SPA + @app.get("/") + async def serve_index(): + return FileResponse(STATIC_DIR / "index.html") - return response + @app.get("/debug") + async def serve_debug(): + return FileResponse(STATIC_DIR / "index.html") - # Utente non trovato - raise HTTPException( - status_code=404, - detail=f"Utente con badge {clean_badge} non trovato nel sistema" + # Monta i file statici (JS, CSS, assets) - DEVE essere dopo le route + app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") + + # Fallback per altri file statici nella root (favicon, ecc.) + @app.get("/{filename:path}") + async def serve_static(filename: str): + file_path = STATIC_DIR / filename + if file_path.exists() and file_path.is_file(): + return FileResponse(file_path) + # Per route SPA sconosciute, serve index.html + return FileResponse(STATIC_DIR / "index.html") + else: + # API-only mode + @app.get("/") + async def root(): + """Endpoint di test per verificare che il server sia attivo""" + return { + "status": "ok", + "message": "Focolari Voting System API is running", + "room": data["room"]["room_name"], + "frontend": "not built - run 'npm run build' in frontend/" + } + + return app + + +def parse_args(): + """Parse degli argomenti da linea di comando""" + parser = argparse.ArgumentParser( + description="Focolari Voting System - Backend Mock Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Esempi: + python main.py # Avvio standard + python main.py -p 9000 # Porta 9000 + python main.py -d data/users_test.json # Dataset test + python main.py --host 127.0.0.1 -p 8080 # Solo localhost + python main.py --api-only # Solo API, no frontend + """ ) - -@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 + parser.add_argument( + "-p", "--port", + type=int, + default=DEFAULT_PORT, + help=f"Porta del server (default: {DEFAULT_PORT})" ) + parser.add_argument( + "-d", "--data", + type=str, + default=DEFAULT_DATA, + help=f"Path al file JSON con i dati (default: {DEFAULT_DATA})" + ) + + parser.add_argument( + "--host", + type=str, + default=DEFAULT_HOST, + help=f"Host di binding (default: {DEFAULT_HOST})" + ) + + parser.add_argument( + "--api-only", + action="store_true", + help="Avvia solo le API senza servire il frontend" + ) + + return parser.parse_args() + + +def main(): + """Entry point principale""" + args = parse_args() + + print("πŸš€ Avvio Focolari Voting System Backend...") + print("=" * 50) + + # Carica i dati + data = load_data(args.data) + + print("=" * 50) + print(f"πŸ“ Server in ascolto su http://{args.host}:{args.port}") + print(f"πŸ“š Documentazione API su http://{args.host}:{args.port}/docs") + if not args.api_only and STATIC_DIR.exists(): + print(f"🌐 Frontend disponibile su http://{args.host}:{args.port}/") + print("=" * 50) + + # Crea e avvia l'app + app = create_app(data, serve_frontend=not args.api_only) + uvicorn.run(app, host=args.host, port=args.port) -# ============================================ -# AVVIO SERVER -# ============================================ if __name__ == "__main__": - import uvicorn - print("πŸš€ Avvio Focolari Voting System Backend...") - print("πŸ“ Server in ascolto su http://localhost:8000") - print("πŸ“š Documentazione API su http://localhost:8000/docs") - uvicorn.run(app, host="0.0.0.0", port=8000) + main() diff --git a/backend-mock/schemas/__init__.py b/backend-mock/schemas/__init__.py new file mode 100644 index 0000000..f4cff9b --- /dev/null +++ b/backend-mock/schemas/__init__.py @@ -0,0 +1,21 @@ +""" +Focolari Voting System - Schemas Package +""" + +from .models import ( + LoginRequest, + EntryRequest, + UserResponse, + RoomInfoResponse, + LoginResponse, + EntryResponse, +) + +__all__ = [ + "LoginRequest", + "EntryRequest", + "UserResponse", + "RoomInfoResponse", + "LoginResponse", + "EntryResponse", +] diff --git a/backend-mock/schemas/models.py b/backend-mock/schemas/models.py new file mode 100644 index 0000000..5f6063d --- /dev/null +++ b/backend-mock/schemas/models.py @@ -0,0 +1,50 @@ +""" +Focolari Voting System - Modelli Pydantic +""" + +from typing import Optional, Literal + +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + """Richiesta login validatore""" + badge: str + password: str + + +class EntryRequest(BaseModel): + """Richiesta ingresso partecipante""" + user_badge: str + validator_password: str + + +class UserResponse(BaseModel): + """Risposta dati utente""" + badge_code: str + nome: str + cognome: str + url_foto: str + ruolo: Literal["Tecnico", "Votante", "Ospite"] + ammesso: bool + warning: Optional[str] = None + + +class RoomInfoResponse(BaseModel): + """Risposta info sala""" + room_name: str + meeting_id: str + server_start_time: int # Timestamp avvio server per invalidare sessioni + + +class LoginResponse(BaseModel): + """Risposta login""" + success: bool + message: str + token: Optional[str] = None + + +class EntryResponse(BaseModel): + """Risposta richiesta ingresso (asettica, senza welcome_message)""" + success: bool + message: str diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..60199be --- /dev/null +++ b/dev.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# ============================================ +# Focolari Voting System - Script di Sviluppo +# ============================================ +# +# Utilizzo: +# ./dev.sh install - Installa dipendenze frontend e backend +# ./dev.sh dev - Avvia frontend (dev) + backend (api-only) +# ./dev.sh build - Builda il frontend +# ./dev.sh server - Builda frontend (se necessario) e avvia server completo +# ./dev.sh backend - Avvia solo il backend (api-only) +# ./dev.sh frontend - Avvia solo il frontend in dev mode +# ./dev.sh shell - Apre shell pipenv del backend +# ./dev.sh clean - Pulisce build e cache +# ./dev.sh help - Mostra questo messaggio +# + +set -e + +# Directory del progetto +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$SCRIPT_DIR/backend-mock" +FRONTEND_DIR="$SCRIPT_DIR/frontend" +FRONTEND_DIST="$FRONTEND_DIR/dist" + +# Colori per output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Funzioni di utilitΓ  +info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +success() { + echo -e "${GREEN}βœ… $1${NC}" +} + +warn() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +error() { + echo -e "${RED}❌ $1${NC}" + exit 1 +} + +# Verifica prerequisiti +check_prereqs() { + command -v node >/dev/null 2>&1 || error "Node.js non trovato. Installalo prima." + command -v npm >/dev/null 2>&1 || error "npm non trovato. Installalo prima." + command -v python3 >/dev/null 2>&1 || error "Python3 non trovato. Installalo prima." + command -v pipenv >/dev/null 2>&1 || error "pipenv non trovato. Installa con: pip install pipenv" +} + +# Installa dipendenze +cmd_install() { + info "Installazione dipendenze..." + + check_prereqs + + # Backend + info "Installazione dipendenze backend (pipenv)..." + cd "$BACKEND_DIR" + pipenv install + success "Backend installato" + + # Frontend + info "Installazione dipendenze frontend (npm)..." + cd "$FRONTEND_DIR" + npm install + success "Frontend installato" + + success "Tutte le dipendenze installate!" +} + +# Build frontend +cmd_build() { + info "Build frontend..." + cd "$FRONTEND_DIR" + npm run build + success "Frontend buildato in $FRONTEND_DIST" +} + +# Verifica se serve rebuild +needs_rebuild() { + # Se dist non esiste, serve build + if [ ! -d "$FRONTEND_DIST" ]; then + return 0 + fi + + # Se dist/index.html non esiste, serve build + if [ ! -f "$FRONTEND_DIST/index.html" ]; then + return 0 + fi + + # Controlla se ci sono file frontend piΓΉ recenti della build + local latest_src=$(find "$FRONTEND_DIR/src" -type f -newer "$FRONTEND_DIST/index.html" 2>/dev/null | head -1) + if [ -n "$latest_src" ]; then + return 0 + fi + + # Controlla anche index.html e config + if [ "$FRONTEND_DIR/index.html" -nt "$FRONTEND_DIST/index.html" ]; then + return 0 + fi + + if [ "$FRONTEND_DIR/vite.config.ts" -nt "$FRONTEND_DIST/index.html" ]; then + return 0 + fi + + return 1 +} + +# Server completo (build + backend) +cmd_server() { + check_prereqs + + # Rebuild se necessario + if needs_rebuild; then + warn "Rilevati cambiamenti nel frontend, rebuild in corso..." + cmd_build + else + info "Frontend giΓ  aggiornato, skip build" + fi + + # Avvia backend con frontend + info "Avvio server completo..." + cd "$BACKEND_DIR" + pipenv run python main.py "$@" +} + +# Solo backend +cmd_backend() { + check_prereqs + info "Avvio backend (API only)..." + cd "$BACKEND_DIR" + pipenv run python main.py --api-only "$@" +} + +# Solo frontend dev +cmd_frontend() { + check_prereqs + info "Avvio frontend in dev mode..." + cd "$FRONTEND_DIR" + npm run dev +} + +# Dev mode: backend api-only + frontend dev (in parallelo) +cmd_dev() { + check_prereqs + + info "Avvio ambiente di sviluppo..." + info "Backend API: http://localhost:8000" + info "Frontend Dev: http://localhost:5173" + echo "" + + # Avvia backend in background + cd "$BACKEND_DIR" + pipenv run python main.py --api-only & + BACKEND_PID=$! + + # Trap per cleanup + trap "kill $BACKEND_PID 2>/dev/null" EXIT + + # Avvia frontend + cd "$FRONTEND_DIR" + npm run dev +} + +# Shell pipenv +cmd_shell() { + info "Apertura shell pipenv backend..." + cd "$BACKEND_DIR" + pipenv shell +} + +# Pulizia +cmd_clean() { + info "Pulizia build e cache..." + + # Frontend + rm -rf "$FRONTEND_DIR/node_modules/.vite" + rm -rf "$FRONTEND_DIST" + + # Backend + find "$BACKEND_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find "$BACKEND_DIR" -type f -name "*.pyc" -delete 2>/dev/null || true + + success "Pulizia completata" +} + +# Help +cmd_help() { + echo "============================================" + echo "Focolari Voting System - Script di Sviluppo" + echo "============================================" + echo "" + echo "Utilizzo: ./dev.sh [opzioni]" + echo "" + echo "Comandi disponibili:" + echo " install Installa dipendenze frontend e backend" + echo " dev Avvia frontend (dev) + backend (api-only) in parallelo" + echo " build Builda il frontend per produzione" + echo " server Builda frontend (se cambiato) e avvia server completo" + echo " backend Avvia solo il backend (api-only)" + echo " frontend Avvia solo il frontend in dev mode" + echo " shell Apre shell pipenv del backend" + echo " clean Pulisce build e cache" + echo " help Mostra questo messaggio" + echo "" + echo "Esempi:" + echo " ./dev.sh install # Setup iniziale" + echo " ./dev.sh dev # Sviluppo (hot reload)" + echo " ./dev.sh server # Produzione locale" + echo " ./dev.sh server -p 9000 # Server su porta 9000" + echo " ./dev.sh server -d data/users_test.json # Con dataset test" + echo "" +} + +# Main +case "${1:-help}" in + install) + cmd_install + ;; + build) + cmd_build + ;; + server) + shift + cmd_server "$@" + ;; + backend) + shift + cmd_backend "$@" + ;; + frontend) + cmd_frontend + ;; + dev) + cmd_dev + ;; + shell) + cmd_shell + ;; + clean) + cmd_clean + ;; + help|--help|-h) + cmd_help + ;; + *) + error "Comando sconosciuto: $1. Usa './dev.sh help' per la lista comandi." + ;; +esac diff --git a/frontend/README.md b/frontend/README.md index d2e7761..38778f7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,12 +4,16 @@ This template provides a minimal setup to get React working in Vite with HMR and Currently, two official plugins are available: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) + uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used + in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) + uses [SWC](https://swc.rs/) for Fast Refresh ## React Compiler -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, +see [this documentation](https://react.dev/learn/react-compiler/installation). ## Expanding the ESLint configuration @@ -43,7 +47,10 @@ export default defineConfig([ ]) ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +You can also +install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) +and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) +for React-specific lint rules: ```js // eslint.config.js diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..95c2c21 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -3,21 +3,21 @@ 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' +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, + 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, + }, }, - }, ]) diff --git a/frontend/index.html b/frontend/index.html index 072a57e..ef363b3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - - - - - - frontend - - -
- - + + + + + + Focolari Voting System + + +
+ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 29329af..6accf61 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.12.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -2265,6 +2266,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3496,6 +3510,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3567,6 +3619,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2551e5a..391880c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.12.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/public/favicon.jpg b/frontend/public/favicon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..173ac37cb8fd293cfacd838b43380afc52204774 GIT binary patch literal 11077 zcmdUUcT`i)_U}PdETAGHN)f+Q=~6;(U+KLhA<~6V0|DvM6$L4VUZe{MgoGj`^r9#o zL`Z0%H-RXLytmIfXFh9Ymot0zK67T~Z1ijfxS^@4p$bq? z000H~0?y{nz0*)qveY-!Q`OK``CG%eSZ6rW&6J|Jc~%KGigE9=Hu$ zA>VvfaF3^d>g#I*|1Iqe|JDuwc!A%#{#&;Hy5Xvgtp}X^f|tn6lJKYQo@A^@#=1V9 zPk-ZBGQI_Ou(BcJ_hiiPPJTf${`R}x`tNw*H@5#fKKPB@A%;o-aE_Xc@7wZj zfLyweefD3=iTq7Y7jPxZz{$_2fG^-qmaqpL$g=$8S}(u@uqBsv6M*-kt;y>kG6Upve zBiG0NvyLMZ0It6Tfa>Od)>-ERKn>Z)ccz|Nxm*3s4kh_{&dv@1wh94&#uxzT$iBW} z`hVO0cYl+0|JD!WjQ{||9{{xb03bCD0JzBZuA7~W14_W9ix;UbUbsX>MRob|r7P68 zXsEAVrM`QU?)ohjM%H^QjLgjUIRv@yv+=VtGe3~v;eRMBCML$pEhQ%@A}c5=Ch}Ve z#pTPFsjpJsp`p1W@(1%DBLBy9_7R}HL=j67OG&{7oTH_nq@_6fLJkND;2b5z?~MA3 zE>cmPzd(8J61i3H20%ecNpXSl(nYEZ7cNqfSt!m?p1(kQk?Q6j!hg~kSh2ZzzKF{y zuec>5{>bnXJ-aB#+GF6hd&ukt11FbQE(iE!e8QlFk}?FkxkXmUL^kx>++ViH<%Nr6 zjTBVmMgdxK<2kZHGF|vP$8T%27yb~w`KJLLn}}6TdECJ4#ary6Zf8V*nv%>;OGyhT z04*Q?MdAMkuoJkbsz`bF&%|}g`>hymQ4=Q=y$D7Rb{>(*%EsNMXU!ELo38Ae{7rHj zfu+7S8UI5)YL-ptD%Mecx59mX%<`SgB+p4-?YjWTNp8p1{Pzl1);$xn(+CclVjxX1WqK$^@Oq zpFKPS#vEQP!l)959)eXXbokh<-C%N^;aP)OwW}A8&67LCJl|NvipJpF-&T6I;DmhqXwMYw^B_9hv+XGqh@w)c9aE zzH6R8{L8)#);Dz~(g&PBQ?}S!E8>NU3X*-~1gkopSe*=E)C`gxh}_*Ty{4jDNdRd! z>>H;=?OQ8b`^U}-$0V)VAU!e`KbsJ`%Th1f=GmvDM~Z7EbKc?`wh)4xuJ3-T9^HMY zCLSJ^wm31G#V7h;VxN(iB{~f0`PF6ClDhJzUF~G%8DO207L?v+AD=yEaiPI}Y&U+q zf#XAo%}Pg5(1WNM!i*+7G)^_Ho!~_K5T)G8jh^co;;|S-#C{&wGHrI^O*iVn78o_p zgXu*XGM*l~?rrJ$+Km0&NtW?{fF5h06KRC;Vj71a%TJ!X>BprgEb#;e%9@-3uY7C) zDjD>D7{vpRGy)W=to$8AHMX&jL&2mv!!w3p=e<|3j*b#}`N~B}lC})gD%-oeH^!5u z>K7JGQiqHO#WxUs(ZwdIB0gE)Vn#`K%DtnvQq6Xg>O+}3p@@9lxU%>xpWsK=y@zZ3 z7Cqgx6U&7rUuVgh_m90yl_^hnyK9Ci5-!V&%=|8uao&FYhEKWe68aMRKai1x4-D)mk7|3o|Hg*HLyx_vTziv}JsC_uV`Mvv9jJ~(*> zc&;b=W{-7NIgXnrz0J+Darwb^sOOW}^M$llAjPT}g)6}K6(+Xvn5fZ5|M5yB3LZ13XQ$^Z%?t=X1Fi&dKMT+<(y|yZ>F{o>bBt_m=)bWtu`X7P z_F3{aYcUc(u@5fYqVj4nGG{LFXm}vn$|ZE&WLIjJUKILug~VaZ=|k_HWz$S!GQaM! zRrYh;QUbAu_HEpe&GPO@3~UrbFm!>lwuABml0Z-Lvnp~DcLt=5vv(g8x{L6};nB8C z@myn)F#_B|B7Z&^fHAH8>{rRpvpn^{MT`D%?70|na*Tc6=R%$1T@^M&Bh%c!cSJU0`)`b!nBd#s$)~Ei zauplLD6O|ddvOlEQf!voWQUd1+nLdhtgJ?lUWWW4`E(ILqWs@`9st~MEL%($vP{Mv zE(JbLJ(y~Yea~cRU>bl#LZUdfG^0IIwl;K?qa5~qU?kQY;js`@L+wg)i*e8;rL;vv zUP79ne)-O-Lr2tzy!`=Gx?p0lXE}!c6#OJWh21VR9^@X4;Mue7prMuBzs{XiFj;|@ zn=1Jus>|9fUS$rYM}j-4z=wpalN;Ndn0vDSw8J(*Ipz4k%+u`6Sh}X*9J|q~U6$x{ z_lwv9ZyAk@EO1NiGp6KYq+E4e)y_bk$&}Mj&r~zl_e-eE9@}F$4oozcxz{r7vUhl3 z)A}Y`D6Uw=Bjr*=5>&Flnmtvcl0Yb_1MAm_YP9)+Br!+Ppnikx$cTX3&|l#TF`i{G z^(CnveNcA{%k9H+EJ4{$Cu1^dYb4k-2~*LTWEBy7Ssk@e- z>BVPdFy9t*CDHt8N|5di;b&*S&3(ED^Xj80xIu|>E1Rd3bWk+%M{wPEmeVS$!fXv( z?x7m~Gs|emrIk5nH{oJy?(@o@NLEsJ1(J%5Bws;#`+b3ndjEd#E<7yIe!!ZsmLQ$G zYAQ1#81I8AJ4hsmyU-T<@K*C$nTq0B=fIEa@HneKyQa2cM=>=sUNR}&D0jjJxLC=N z#G<3$mD<-NgU8%lwzt_3e>@~%ma2E1G94tDWx8qlcD6=ML%_?-^II-@fCOb z;EcA`YLk`ysaiy!k*AV|ey*1VE-*@$U$l*V{|vaCsiXehuVitdXbNeHT(5<4I3`-V z`8OmSWl=}qHAm-N%oba@eI=^(MW!`J6QWY0GN%ffWAKF@LK45&`}bTX#*QF!&9;?# zS9#`~4RDO@1++Kz9%l4rD;6uO(au_?E;T-<{JoI)_e7!cBi(NtXKQ~!6(Z!)w%)H0 zq(0_~p69erqNHnS$paCL0e0Tj^pVmLN(llP7nOt^JN{+Ut$Ih^am5HzDhXZ}VwEt=cNVW!FM&7a%eg&H*D5P4IZ!F~ ze^z#BdqBE~?bGHhWHeWKQ5f#w>zo}FizZ^Gl=s;83>XR)H}3JRdQMsXpC;Vj?xgsB zFvbooRd(mKGVse$cM=dek@J8l51HIeGGaKTC$(*RZV0h!{&31~7+=iA#su-u-FwhP z&ybrlRD_e>Um^z7)>O^gv)0X*c0s{jXt2#Wn^vSAURF9_nHcCIfK6R@U#siM z5UM@4qE5{tjMUS}2JFQ9 zg0EkYbx{XNuqka%XGjl@A;w|xbDd+UJojXCn4+M=^M4!z0thn!LXEWA;P9g^ zD?clfh5bVJgCyv_{l1L$8K7OcKGfRbibI`X=^1#IwhKShQuBEbreE4~-8~Knkn$GW zTv~Tk5b)Dr72p@qx;H=d#Uri73o4n@Pbh|&FcGepr*ok?T;=&XtGmKwtZPr!X$-2u^=qXZj9WfgapeOoWBEQJ7_|Rl-K)Cm6Z~7=p zlWpOKik@F=2rPeTDkomByhT2rI~bqJ))17s|0>w9Ow#utCE!kXs*w7Pzk2td%k~?c zlqp^~m8UU0VZDSc%jlr1GfEgo`}e8VnUN#@DZP(hhw5;E-1t3IV-Wj0_m8qw3#NZ1 zt_ClTW9yt1ocBZhysB39;2!Ej0jXf0kv;`cM5VU+5X?}car)zF5I&Y<(tS5={}VfN zn~KV0xBXWm9xM*O#}~i5DIXYL-4_MFHO#4?G81@DUjg?b-U{rCZqx%WL$j>8=%*xq z0@oG)b0_}dXs3QI{PGIi@SXtn8WA<kjH6p@KUrqp!I^HsKH5#na|X%AP@82b=6St8p3DeVDphBvi6e?uFX_^@#mro z*jEzg%PSQ5fN3VeF;L0$X~RT`4+jb-7PIBUR3D8HWGG7wwAX`J7Yf03z<;5@xSKvnnt?o38Q-}nU;q-}h1!E9|3ooE#V z{xm$Qjc)8^();zHlzMEHm&Q#_j*|?3R0nmP^zxiY8lb^{SOmF28fOrJ$-K9UalYj2**JIIY_D!FEDxaWq0Gv1tM}`pjtL2No&w zV<~K!gO+Q1&f3*VdTT!BG2O2;T+J0ze|9g(&j`*qwiy3)Dc?VetQtj*?ZcYIU0fHL zdzZYrK7r-bdOdXtC(MUl zxUc?1fs5eys&cH=Drw1l)JkP(u9;aeHstd7DrM!7A^01dXGp zv={!cNg!2*xskeUAxo2`(z)aInCL?t9Rm$n-Kd%ZOI~iNhpDmLb$b?s4 zY3lASWQPvlp1!5TI_%`PHK)a&;^#k!;XMk-=N2UAtY-U~GP$GQT5!~hK|;}u^o-fSeLq9- zGoXq_86EM-ENi4dx8NHwd+bVR2M@dD5h_puS&~1T(p|J-8+|#+NQ!v^bP7`nHZvfM ztVFD=+z;5Q`w4;5@z&}~GrD;cAR}8&s)BiG>d z0~K7u?4+fPb=#XtXVB;j{irGD5mSpib0zBt+*Ljw1W!uXzF%R`(<~`H$(zTo+`3%R zHs~m(Tt6Av^3X2YG5@R+QE4d{O_BM;&K4tfQ`UtcP%rqsSAF~^&~@j@F1yT8W!+kf zI|uQZa`w?}b|IlL(?*N^LBfrT-`}Zt-E(GK8}~<8UnwI%g!EdQSBK7kOGC>wlQ2ho zui2NVaR{88M~e(Z9~>%kBvS%>-e8>|db2g6&n%PfYpPOQZV8OO9%dnQYfA;nZh8yp z|CBt(m&&PUedFp?b~2!S81ac#`i6Pt=?1|fIa{=gxUrz*DOX<|Qhjh#Z~Vl0=+g4g zYWtS$FEH`BFH*-X9}ZbDoR0fa|0296P}C$mJWiOFR`$l5)!Wmi9{4wfy+Tt#bjXMCwSwMZZ@iv=yd|66F|9rPKLg=x8VMD*K+2~$}gmt zvo~oJbh1`$VhqnueH#svpz*oAwZ}sk7Q@lk8&#*JarcQ1q!(M;!mg|QO1y5){aKyZ zbT|9lb?qPcy_bH0MOD#Qr;MQ^LyYfTWo>^(-5mlQ*s;M&PT3{;@yh5(c~>YR*X@x4 znqNXwKrt<{by!Qf#@o*)aHn0;CpVY-TA==Qnb6xcb>rW2(a{5=y^|v@O`WwHGwNdb z^`-d2A~Y77S*5vP-r>r|hQU}peSQ5HR>T2^ztn_qOeER3=h01Q39Mnf&R;8m8$*g! zl1dh$NfEu5Y-Jcv%AwdedQ+{&tgE$Oei2g5#!pGuQ>9ZV`>j3F@TzL*49VCKMHok= zg1exT)^Pd!y_-=lEjjD+-yoXSB9_B5VP1-Focn*q`zf6P>@drqQ@llNiX+>xRs#4YpsytZ#&pj9EpWi`LJrs=L*p7)L> z_LaLW!`=06w!-4es$EUBBu zzLXo*$625H7|WkfYVnK90HsH-tSH?ecg&=9awzY`p3-4vLkw_qb-sqNxjf3LYVdXv zY8lmJQ>ZjR_~vMW@T+A#-{%>*JoOgRlcbLyO7pI-s88Ou_cwLRZ*{4ACWo%m4G?X6 z=GA%6u+qGbZZ4irqHu}rP#GpS$JeFrBt85}_+G9a-SL4Y7N8~{>^uQ3KK#8?_3>{y zsjaAI0LqI==2Me@@~Xhh^(coV;wscpP~B5*94Au1Q%itjv!79L?U#!hmwzqA`g7n; z>s_+wMd~M=6rJRgj5}X}uN1G`d4E^_gD8{GWi;i+7`_aGM_TwO5xbkwn6SOgmYA36 zPJr1=c_u+C8))=a1{kyEJ`yv&BI~Em2_2$L!GN z)&ePRbYnQ~_KWgw6Q-ujV5wg3ZT5gvI4n08J|A!R4hTs>6^bRs;a2p!A^LjMKp*zeQZt)lNt_^al|Ej_ z*;K~P8h`bH(YaG&%8Ujz!kv?R?$g(YDL1{u$Xj;f3ctL#PhSS-RPv)jxI6c)-XQ9F zCc>%2kArhQ7zDDF|1wXE7T@$3`J^Bp>iU$ZGFPC+$z=6Ds(`6^-K6Zg+o$F!yOt@= z;-I6v%m(V<&`esnDI9-^a1I(tDA#j)kpwE!uH(@8uG-WYHPWW|oU4Xl+`r<>P)3M3 z102Vyk%F`5(J2q$nAh01=`qR&JPz_9ahu+{`7Z`eFQDk<-rgs6DB-1}O+!mL!W`l( zIr#NBS4rqh29}ABX(nq`DSlkgf$n(awR-{<=%N9g!T5oc{V^f8ysQ>ww7Z(9$0yOQ zDbItag1ygSY=XB!5G=XiZ@NdOW=KpD2ECHef<^2qcMKmeF)24ok2~7Z%M9V@eN{YQ zS4Q#KHIjR=EPMsi=u)TKTWwl5*$d)|+UWAYZ9#y)UKxnFzklU?r~`2@7xkj*uOzM4JmkZw)%7jIvHGynP>=yRAVO$=G5sb}*))yTE ze(?WByqrWr4!(!*5`1!mtY_Vi>&ut_n#)B_`U)mKOs-#OgbiF<6W z+$y?q%NLNwRZ$DwO)cPEBjg>ci~mV%MHj8Y6cRZ(Za>wEHsqtz=~0!878JHmWN zU3@-M&A4CD8C4&2ZnkjpRT}H2AnUqw0g!RMV->CCqV3wt`PT}= zSXi}u>op0Uafj;dn;u(@j9IytJ&6Kyr7(e;6_Lz`G9_vX!`RQGuh}LqbQoy-X%|Xf z9KZDUP=JPGQ>3P^Ti?zsR5nJsNr)9=Qt7&ra~isvOV!T{X^^6J`wa&M_1pqJG<8=9 zKmPh49Mc$EDG2V;LSWGm;HpTfCQMOcHwa3tVqg79zdPK)F*h|FJ<@R#WWW%zCGV;c zMbj~0dwA;#aiGir{<5cWDt)s{`nguMdS&~$Oo9%)h8m#Hn2%mPO`$pM(DnD`u^&l=*T zx33<|YfN-nG&&TGyDsj9Ch?m(3TGUE^O5?GlT;@uzUh_nTvacs7*6@#*C3lPI2T}8;T{;|^U zAmrg5%O3MN(mK7QDxR|!mNEeTZm3tOP_e#~^6SRsxUD6211OZ1E^^@@%MRRn_}*Py zl341I63@m>&ttBE<#XK?LZ~00J;G-Y^E^{)yOvLH=i8N(>&uzSeWh72b7{xys+x@js!HX+8(l^uIcjvh7B$j52|oS99SBJonai-fk|x3K2*#Ny{!NF815UnM`*n z&N}HBI}vVoKFLF>IWc$s>bggQUy17AF72T^S#dj64i-r}!fTjT?bc`cNf4wpI43NI zmKWMdWy(tSw%JXB3Z3@7?@Ty#s>th7NONSiXp~J+b@pCl$^hN>r!1K%-&DtuwthGn z4NmnqzdV!h7W)0$6xYTI$DlhYr^;U_a{Ny|yX_bUK_&XGPOmY=24Z!QkKC)JpAs}wgJleB9%xmwOoKA_~yyM^`{6I?BK{fRG}fB6>5w zGY%IFJFl^jyS3N{x#sEC?^#>T#kH%VJ*pkVEp(Xuj^_yy#1GlbWw(Yup73FN@^V>+ zRiJ6btG1&-zvRoqh751aJbBJ2-AXRWsdF696@kQO6>z(% zu`^s+pF`|Z1TFB+V9?^3ZiNxWC3#`_2J#JN<#hqSz8zzTp{>f0{2Kh1+IEXwzue>4 zt6Cfkj@3($v6UaA6D9m_{q}`NwT}(12U0vMYf^E`nl-GHi^nm(RaWJaV)Nu268knZ z*rO*yyQ$s=Pirzz#E5(NM28%5GWSnLm!pRlXlmo8-YD@tB~b3|BQ{h$$*E$N6=ux4X@Sza7f#rxAdW3I4W;EI-Rbpw zW6a$0r6{bhDKPX)Ml@Fne!%gEAsEzoIMuMN5mUT={rut?@N)9BX6uetX{RV)G2K&sFH{c^{8xfPzZVl9HN_qGpn>F{jX2>y#>I|#o5^w2`S!V(P;jk_e3Vdk!Qnr0 z{aoxnvsz!;mw`OH&63ZQl(zo}{?{a@$Z@YUEnQ)R4XWwK@nR+Lq@~m-Wc-+eXLQX# zn{EXg`##+dxa2|6?_)bFgKMB3O=FjSV_r0Tl_4W;G1}6^Lq_pZ%CQuFdLMN442YgD zbH;l|DaGwT3@Vhhy`vTth>f}*D4qk9CKVy^%@;ySvv}q#WGX=PG_mK6%(GU;Y+(8P z1Q^bH_rB(1>>ya>yyHF`!Nzo-Vw1z5 zhp|Ex^wXVuFw@QJ8@_7v5$72f>bP!ovgsO*#OU{6l7pY^C;nGD^I z=jB7vSKn)oOn$(=hN|t)ZX^O$v~+Q?z;`pT6U+}8yRw`xU&cv-2c9>g|6*J7xEiAH zNRCG4euP=+ggt*5*clLWF-p5yESKcP?}3wY<-WBoBqs5kmfLwr7qj#MttYl7Q9_h% zL#A?uyeLg#cyO3xV^kLA%d$a*g7VWR4uTf3mpU&_R zKDxWgbDoNnsc!ha^s3dg<}k(x#>w`&XZI4;C6pd{EFjz786v4lf3^fMe9DK(k&!f{jUT6{~TsL G8~Yzz$y-4H literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index febd238..d3d2f3c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,374 +3,400 @@ * 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'; +import {useCallback, useEffect, useState} from 'react'; +import {useRFIDScanner} from './hooks/useRFIDScanner'; +import {ActiveGateScreen, ErrorModal, LoadingScreen, SuccessModal, ValidatorLoginScreen,} from './screens'; +import {ApiError, getRoomInfo, getUserByBadge, loginValidator, requestEntry,} from './services/api'; +import type {AppState, RoomInfo, User, ValidatorSession} from './types'; // Costanti -const VALIDATOR_BADGE = '999999'; -const VALIDATOR_PASSWORD = 'focolari'; const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minuti const USER_TIMEOUT_SECONDS = 60; const STORAGE_KEY = 'focolari_validator_session'; function App() { - // ============================================ - // State - // ============================================ - const [appState, setAppState] = useState('loading'); - const [roomInfo, setRoomInfo] = useState(null); - const [validatorSession, setValidatorSession] = useState(null); - const [pendingValidatorBadge, setPendingValidatorBadge] = useState(null); - const [currentUser, setCurrentUser] = useState(null); - const [error, setError] = useState(undefined); - const [loading, setLoading] = useState(false); + // ============================================ + // State + // ============================================ + const [appState, setAppState] = useState('loading'); + const [roomInfo, setRoomInfo] = useState(null); + const [validatorSession, setValidatorSession] = useState(null); + const [pendingValidatorBadge, setPendingValidatorBadge] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [error, setError] = useState(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(''); + // Modal states + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [successUserName, setSuccessUserName] = useState(undefined); + const [showErrorModal, setShowErrorModal] = useState(false); + const [errorModalMessage, setErrorModalMessage] = useState(''); - // ============================================ - // Session Management - // ============================================ + // Notifica badge validatore ignorato + const [showValidatorBadgeNotice, setShowValidatorBadgeNotice] = useState(false); - const saveSession = useCallback((session: ValidatorSession) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); - setValidatorSession(session); - }, []); + // Badge non trovato (con timeout per tornare all'attesa) + const [notFoundBadge, setNotFoundBadge] = useState(null); - const clearSession = useCallback(() => { - localStorage.removeItem(STORAGE_KEY); - setValidatorSession(null); - setPendingValidatorBadge(null); - setCurrentUser(null); - setAppState('waiting-validator'); - }, []); + // ============================================ + // Session Management + // ============================================ - const loadSession = useCallback((): ValidatorSession | null => { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return null; + const saveSession = useCallback((session: ValidatorSession) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + setValidatorSession(session); + }, []); - const session: ValidatorSession = JSON.parse(stored); - - // Check if session is expired - if (Date.now() > session.expiresAt) { + const clearSession = useCallback(() => { 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); + setValidatorSession(null); + setPendingValidatorBadge(null); setCurrentUser(null); + setAppState('waiting-validator'); + }, []); + + const loadSession = useCallback((serverStartTime?: number): ValidatorSession | null => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + + const session: ValidatorSession = JSON.parse(stored); + + // Check if session is expired + if (Date.now() > session.expiresAt) { + console.log('[FLOW] Session expired by time'); + localStorage.removeItem(STORAGE_KEY); + return null; + } + + // Check if server restarted (invalidate old sessions) + if (serverStartTime && session.serverStartTime !== serverStartTime) { + console.log('[FLOW] Session invalidated - server restarted'); + localStorage.removeItem(STORAGE_KEY); + return null; + } + + return session; + } catch { + return null; + } + }, []); + + // ============================================ + // RFID Scanner Handler + // ============================================ + + const handleRFIDScan = useCallback(async (code: string) => { + console.log('[RFID] Badge scansionato:', code); + + // Pulisci il codice + const cleanCode = code.trim(); + + switch (appState) { + case 'waiting-validator': + // Qualsiasi badge puΓ² essere un validatore - verrΓ  verificato con la password + console.log('[FLOW] Transition: waiting-validator -> validator-password'); + setPendingValidatorBadge(cleanCode); + setAppState('validator-password'); + break; + + case 'validator-password': + // Ignora badge durante inserimento password + console.log('[FLOW] Badge ignorato durante inserimento password'); + break; + + case 'gate-active': + case 'showing-user': + // Se Γ¨ il badge del validatore attuale + if (validatorSession && cleanCode === validatorSession.badge) { + if (currentUser && currentUser.ammesso) { + console.log('[FLOW] Validator badge detected - confirming entry'); + // Conferma ingresso + await handleEntryConfirm(); + } else { + // Badge validatore passato senza utente ammesso - mostra notifica + console.log('[FLOW] Validator badge ignored - showing notice'); + setShowValidatorBadgeNotice(true); + setTimeout(() => setShowValidatorBadgeNotice(false), 4000); + } + } else { + console.log('[FLOW] Loading participant:', cleanCode); + // Badge partecipante - carica utente + // Se c'era un badge non trovato, cancellalo e carica il nuovo + setNotFoundBadge(null); + await handleLoadUser(cleanCode); + } + break; + + default: + break; + } + }, [appState, currentUser, validatorSession]); + + // ============================================ + // Initialize RFID Scanner + // ============================================ + + const {state: rfidState, buffer: rfidBuffer} = useRFIDScanner({ + onScan: handleRFIDScan, + onTimeout: () => { + console.warn('[App] RFID timeout - lettura incompleta'); + }, + disabled: appState === 'loading', + }); + + // ============================================ + // API Handlers + // ============================================ + + const handleLoadUser = useCallback(async (badgeCode: string) => { + setLoading(true); + setError(undefined); + setNotFoundBadge(null); + setAppState('showing-user'); + + try { + const user = await getUserByBadge(badgeCode); + setCurrentUser(user); + } catch (err) { + if (err instanceof ApiError && err.statusCode === 404) { + // Utente non trovato - mostra schermata speciale con timeout + console.log('[FLOW] Badge not found:', badgeCode); + setNotFoundBadge(badgeCode); + setCurrentUser(null); + } else { + const message = err instanceof ApiError + ? err.detail || err.message + : 'Errore durante il caricamento utente'; + setError(message); + setCurrentUser(null); + } + } finally { + setLoading(false); + } + }, []); + + const handleEntryConfirm = useCallback(async () => { + if (!currentUser || !validatorSession) return; + + setLoading(true); + + // Salva il nome utente prima di pulirlo + const userName = `${currentUser.nome} ${currentUser.cognome}`; + + try { + const response = await requestEntry( + currentUser.badge_code, + validatorSession.password // Uso la password dalla sessione + ); + + if (response.success) { + console.log('[FLOW] Entry confirmed for:', userName); + setSuccessUserName(userName); + setShowSuccessModal(true); + setCurrentUser(null); + setAppState('gate-active'); + } + } catch (err) { + const message = err instanceof ApiError + ? err.detail || err.message + : 'Errore durante la registrazione ingresso'; + setErrorModalMessage(message); + setShowErrorModal(true); + } finally { + setLoading(false); + } + }, [currentUser, validatorSession]); + + const handlePasswordSubmit = useCallback(async (password: string) => { + if (!pendingValidatorBadge || !roomInfo) return; + + setLoading(true); + setError(undefined); + + try { + const response = await loginValidator(pendingValidatorBadge, password); + + if (response.success) { + console.log('[FLOW] Validator authenticated, badge:', pendingValidatorBadge); + const session: ValidatorSession = { + badge: pendingValidatorBadge, + password: password, // Salvo la password per le conferme ingresso + token: response.token || '', + loginTime: Date.now(), + expiresAt: Date.now() + SESSION_DURATION_MS, + serverStartTime: roomInfo.server_start_time, // Per invalidare se server riparte + }; + + saveSession(session); + setPendingValidatorBadge(null); + setAppState('gate-active'); + } + } catch (err) { + const message = err instanceof ApiError + ? err.detail || err.message + : 'Errore durante il login'; + setError(message); + } finally { + setLoading(false); + } + }, [pendingValidatorBadge, saveSession, roomInfo]); + + // ============================================ + // UI Handlers + // ============================================ + + const handleCancelPassword = useCallback(() => { + setPendingValidatorBadge(null); + setError(undefined); + setAppState('waiting-validator'); + }, []); + + const handleCancelUser = useCallback(() => { + setCurrentUser(null); + setNotFoundBadge(null); + setError(undefined); setAppState('gate-active'); - } - } 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; + const handleUserTimeout = useCallback(() => { + console.log('[App] User timeout - tornando in attesa'); + setCurrentUser(null); + setNotFoundBadge(null); + setError(undefined); + setAppState('gate-active'); + }, []); - setLoading(true); - setError(undefined); + const handleSuccessModalClose = useCallback(() => { + setShowSuccessModal(false); + setSuccessUserName(undefined); + }, []); - try { - const response = await loginValidator(pendingValidatorBadge, password); + const handleErrorModalClose = useCallback(() => { + setShowErrorModal(false); + setErrorModalMessage(''); + }, []); - if (response.success) { - const session: ValidatorSession = { - badge: pendingValidatorBadge, - token: response.token || '', - loginTime: Date.now(), - expiresAt: Date.now() + SESSION_DURATION_MS, + // ============================================ + // Initialization + // ============================================ + + useEffect(() => { + const init = async () => { + try { + // Carica info sala + const info = await getRoomInfo(); + setRoomInfo(info); + + // Verifica sessione esistente (passa serverStartTime per invalidazione) + const existingSession = loadSession(info.server_start_time); + + if (existingSession) { + setValidatorSession(existingSession); + setAppState('gate-active'); + } else { + setAppState('waiting-validator'); + } + } catch (err) { + const message = err instanceof ApiError + ? err.detail || err.message + : 'Impossibile connettersi al server'; + setError(message); + } }; - saveSession(session); - 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); + 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 ( + window.location.reload()} + /> + ); } - }, [pendingValidatorBadge, saveSession]); - // ============================================ - // UI Handlers - // ============================================ + // No room info - error + if (!roomInfo) { + return ( + window.location.reload()} + /> + ); + } - const handleCancelPassword = useCallback(() => { - setPendingValidatorBadge(null); - setError(undefined); - setAppState('waiting-validator'); - }, []); + // Validator login screens + if (appState === 'waiting-validator' || appState === 'validator-password') { + return ( + + ); + } - 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') { + // Active gate screen return ( - window.location.reload()} - /> + <> + + + {/* Success Modal */} + + + {/* Error Modal */} + + ); - } - - // No room info - error - if (!roomInfo) { - return ( - window.location.reload()} - /> - ); - } - - // Validator login screens - if (appState === 'waiting-validator' || appState === 'validator-password') { - return ( - - ); - } - - // Active gate screen - return ( - <> - - - {/* Success Modal */} - - - {/* Error Modal */} - - - ); } export default App; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 8859315..125def3 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -3,88 +3,88 @@ */ interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' | 'success'; - size?: 'sm' | 'md' | 'lg'; - fullWidth?: boolean; - loading?: boolean; + 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'; + 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 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 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' : ''; + const widthClass = fullWidth ? 'w-full' : ''; - return ( - - ); + ) : ( + children + )} + + ); } export default Button; diff --git a/frontend/src/components/CountdownTimer.tsx b/frontend/src/components/CountdownTimer.tsx index 78a1196..59c415c 100644 --- a/frontend/src/components/CountdownTimer.tsx +++ b/frontend/src/components/CountdownTimer.tsx @@ -2,100 +2,100 @@ * Countdown Timer Component - Focolari Voting System */ -import { useState, useEffect, useCallback } from 'react'; +import {useCallback, useEffect, useState} 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; + /** 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); + 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')}`; - }, []); + 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(() => { + setRemaining(seconds); + }, [seconds]); - useEffect(() => { - if (paused || remaining <= 0) { - return; - } - - const timer = setInterval(() => { - setRemaining((prev) => { - if (prev <= 1) { - clearInterval(timer); - onExpire(); - return 0; + useEffect(() => { + if (paused || remaining <= 0) { + return; } - return prev - 1; - }); - }, 1000); - return () => clearInterval(timer); - }, [paused, remaining, onExpire]); + const timer = setInterval(() => { + setRemaining((prev) => { + if (prev <= 1) { + clearInterval(timer); + onExpire(); + return 0; + } + return prev - 1; + }); + }, 1000); - const getColorClass = (): string => { - if (remaining <= dangerThreshold) { - return 'text-error'; - } - if (remaining <= warningThreshold) { - return 'text-warning'; - } - return 'text-focolare-blue'; - }; + return () => clearInterval(timer); + }, [paused, remaining, onExpire]); - const getBarColorClass = (): string => { - if (remaining <= dangerThreshold) { - return 'bg-error'; - } - if (remaining <= warningThreshold) { - return 'bg-warning'; - } - return 'bg-focolare-blue'; - }; + const getColorClass = (): string => { + if (remaining <= dangerThreshold) { + return 'text-error'; + } + if (remaining <= warningThreshold) { + return 'text-warning'; + } + return 'text-focolare-blue'; + }; - const progressPercent = (remaining / seconds) * 100; + const getBarColorClass = (): string => { + if (remaining <= dangerThreshold) { + return 'bg-error'; + } + if (remaining <= warningThreshold) { + return 'bg-warning'; + } + return 'bg-focolare-blue'; + }; - return ( -
-
- {formatTime(remaining)} -
+ const progressPercent = (remaining / seconds) * 100; - {showBar && ( -
-
+ return ( +
+
+ {formatTime(remaining)} +
+ + {showBar && ( +
+
+
+ )}
- )} -
- ); + ); } export default CountdownTimer; diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index d137bb4..9934699 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -2,45 +2,78 @@ * Input Component - Focolari Voting System */ -import { forwardRef } from 'react'; +import {forwardRef, useState} from 'react'; interface InputProps extends React.InputHTMLAttributes { - label?: string; - error?: string; - fullWidth?: boolean; + label?: string; + error?: string; + fullWidth?: boolean; } export const Input = forwardRef( - ({ label, error, fullWidth = true, className = '', ...props }, ref) => { - const widthClass = fullWidth ? 'w-full' : ''; + ({label, error, fullWidth = true, className = '', type, ...props}, ref) => { + const widthClass = fullWidth ? 'w-full' : ''; + const isPassword = type === 'password'; + const [showPassword, setShowPassword] = useState(false); - return ( -
- {label && ( - - )} - - {error && ( -

{error}

- )} -
- ); - } + const inputType = isPassword && showPassword ? 'text' : type; + + return ( +
+ {label && ( + + )} +
+ + {isPassword && ( + + )} +
+ {error && ( +

{error}

+ )} +
+ ); + } ); Input.displayName = 'Input'; diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index 94a255e..ed9e6a1 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -5,40 +5,40 @@ import FocolareLogo from '../assets/FocolareMovLogo.jpg'; interface LogoProps { - size?: 'sm' | 'md' | 'lg'; - showText?: boolean; + 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', - }; +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', - }; + const textSizeClasses = { + sm: 'text-lg', + md: 'text-xl', + lg: 'text-2xl', + }; - return ( -
- Movimento dei Focolari - {showText && ( -
+ return ( +
+ Movimento dei Focolari + {showText && ( +
Movimento dei Focolari - Sistema Votazioni + Sistema Votazioni +
+ )}
- )} -
- ); + ); } export default Logo; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 30dcdc6..26fbcd5 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -2,79 +2,79 @@ * Modal Component - Focolari Voting System */ -import { useEffect } from 'react'; +import {useEffect} from 'react'; interface ModalProps { - isOpen: boolean; - onClose?: () => void; - variant?: 'success' | 'error' | 'info'; - autoCloseMs?: number; - fullscreen?: boolean; - children: React.ReactNode; + 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; + 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 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 = ''; + const variantClasses = { + success: 'bg-success', + error: 'bg-error', + info: 'bg-focolare-blue', }; - }, [isOpen]); - if (!isOpen) { - return null; - } + 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 variantClasses = { - success: 'bg-success', - error: 'bg-error', - info: 'bg-focolare-blue', - }; + 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'; - 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 ( -
-
e.stopPropagation()} - > - {children} -
-
- ); + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); } export default Modal; diff --git a/frontend/src/components/NumLockBanner.tsx b/frontend/src/components/NumLockBanner.tsx new file mode 100644 index 0000000..404d7bb --- /dev/null +++ b/frontend/src/components/NumLockBanner.tsx @@ -0,0 +1,132 @@ +/** + * NumLock Banner Component - Focolari Voting System + * + * Mostra un banner di avviso su browser desktop quando NumLock + * potrebbe essere necessario per il corretto funzionamento del lettore RFID. + * Non viene mostrato su dispositivi touch (tablet, smartphone). + */ + +import {useEffect, useState} from 'react'; + +interface NumLockBannerProps { + className?: string; +} + +export function NumLockBanner({className = ''}: NumLockBannerProps) { + const [isDesktop, setIsDesktop] = useState(false); + const [numLockState, setNumLockState] = useState(null); + const [dismissed, setDismissed] = useState(false); + + // Rileva se siamo su desktop (non touch device) + useEffect(() => { + const checkIfDesktop = () => { + // Controlla se è un dispositivo touch + const isTouchDevice = + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + // @ts-expect-error - msMaxTouchPoints è specifico di IE/Edge + navigator.msMaxTouchPoints > 0; + + // Controlla user agent per mobile/tablet + const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + const isMobileUA = mobileRegex.test(navigator.userAgent); + + // È desktop solo se non è touch E non ha UA mobile + setIsDesktop(!isTouchDevice && !isMobileUA); + }; + + checkIfDesktop(); + }, []); + + // Ascolta eventi tastiera per rilevare stato NumLock + useEffect(() => { + if (!isDesktop) return; + + const handleKeyDown = (event: KeyboardEvent) => { + // getModifierState restituisce lo stato di NumLock + const numLock = event.getModifierState('NumLock'); + setNumLockState(numLock); + }; + + // Aggiungi listener + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isDesktop]); + + // Non mostrare su mobile/tablet o se dismissato + if (!isDesktop || dismissed) { + return null; + } + + return ( +
+
+ {/* Icona */} +
+ + + +
+ + {/* Contenuto */} +
+

+ ⌨️ Modalità Desktop Rilevata +

+

+ Per il corretto funzionamento del lettore RFID, assicurati che il tasto + Bloc Num (NumLock) sia attivo. +

+ + {/* Stato NumLock */} +
+ Stato attuale: + {numLockState === null ? ( + + Premi un tasto per rilevare... + + ) : numLockState ? ( + + + NumLock ATTIVO βœ“ + + ) : ( + + + NumLock DISATTIVO βœ— + + )} +
+
+ + {/* Pulsante chiudi */} + +
+
+ ); +} + +export default NumLockBanner; diff --git a/frontend/src/components/RFIDStatus.tsx b/frontend/src/components/RFIDStatus.tsx index c6ebd95..5743306 100644 --- a/frontend/src/components/RFIDStatus.tsx +++ b/frontend/src/components/RFIDStatus.tsx @@ -3,31 +3,31 @@ * Mostra lo stato del lettore RFID */ -import type { RFIDScannerState } from '../types'; +import type {RFIDScannerState} from '../types'; interface RFIDStatusProps { - state: RFIDScannerState; - buffer?: string; + state: RFIDScannerState; + buffer?: string; } -export function RFIDStatus({ state, buffer }: RFIDStatusProps) { - if (state === 'idle') { - return ( -
-
- RFID Pronto -
- ); - } +export function RFIDStatus({state, buffer}: RFIDStatusProps) { + if (state === 'idle') { + return ( +
+
+ RFID Pronto +
+ ); + } - return ( -
-
- + return ( +
+
+ Lettura in corso... {buffer && `(${buffer.length} caratteri)`} -
- ); +
+ ); } export default RFIDStatus; diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx index fd3dba9..c8580cc 100644 --- a/frontend/src/components/UserCard.tsx +++ b/frontend/src/components/UserCard.tsx @@ -2,99 +2,99 @@ * User Card Component - Focolari Voting System */ -import type { User } from '../types'; +import type {User} from '../types'; interface UserCardProps { - user: User; - size?: 'compact' | 'full'; + user: User; + size?: 'compact' | 'full'; } -export function UserCard({ user, size = 'full' }: UserCardProps) { - const roleColors: Record = { - Votante: 'bg-focolare-blue text-white', - Tecnico: 'bg-focolare-orange text-white', - Ospite: 'bg-gray-500 text-white', - }; +export function UserCard({user, size = 'full'}: UserCardProps) { + const roleColors: Record = { + 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'; + const statusClass = user.ammesso + ? 'border-success bg-success/10' + : 'border-error bg-error/10 animate-pulse-error'; - if (size === 'compact') { - return ( -
-
- {`${user.nome} { - (e.target as HTMLImageElement).src = - 'https://via.placeholder.com/100?text=' + user.nome.charAt(0); - }} - /> -
-

- {user.nome} {user.cognome} -

- + if (size === 'compact') { + return ( +
+
+ {`${user.nome} { + (e.target as HTMLImageElement).src = + 'https://via.placeholder.com/100?text=' + user.nome.charAt(0); + }} + /> +
+

+ {user.nome} {user.cognome} +

+ {user.ruolo} -
-
-
- ); - } +
+
+
+ ); + } - return ( -
- {/* Foto e Dati Principali */} -
- {`${user.nome} { - (e.target as HTMLImageElement).src = - 'https://via.placeholder.com/200?text=' + user.nome.charAt(0); - }} - /> + return ( +
+ {/* Foto e Dati Principali */} +
+ {`${user.nome} { + (e.target as HTMLImageElement).src = + 'https://via.placeholder.com/200?text=' + user.nome.charAt(0); + }} + /> -
-

- {user.nome} {user.cognome} -

+
+

+ {user.nome} {user.cognome} +

-
+
{user.ruolo} - + {user.ammesso ? 'βœ“ AMMESSO' : 'βœ— NON AMMESSO'} -
+
-

- Badge: {user.badge_code} -

-
-
+

+ Badge: {user.badge_code} +

+
+
- {/* Warning Box */} - {user.warning && ( -
-

- ⚠️ {user.warning} -

+ {/* Warning Box */} + {user.warning && ( +
+

+ ⚠️ {user.warning} +

+
+ )}
- )} -
- ); + ); } export default UserCard; diff --git a/frontend/src/components/WelcomeCarousel.tsx b/frontend/src/components/WelcomeCarousel.tsx new file mode 100644 index 0000000..8edb725 --- /dev/null +++ b/frontend/src/components/WelcomeCarousel.tsx @@ -0,0 +1,100 @@ +/** + * Welcome Carousel Component - Focolari Voting System + * + * Carosello automatico di messaggi di benvenuto multilingua. + * Scorre automaticamente ogni 800ms durante la visualizzazione. + */ + +import {useEffect, useState} from 'react'; + +interface WelcomeMessage { + lang: string; + text: string; +} + +const WELCOME_MESSAGES: WelcomeMessage[] = [ + {lang: 'it', text: 'Benvenuto!'}, + {lang: 'en', text: 'Welcome!'}, + {lang: 'fr', text: 'Bienvenue!'}, + {lang: 'de', text: 'Willkommen!'}, + {lang: 'es', text: 'Β‘Bienvenido!'}, + {lang: 'pt', text: 'Bem-vindo!'}, + {lang: 'zh', text: '欒迎!'}, + {lang: 'ja', text: 'γ‚ˆγ†γ“γ!'}, + {lang: 'ar', text: '!Ω…Ψ±Ψ­Ψ¨Ψ§Ω‹'}, + {lang: 'ru', text: 'Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ!'}, +]; + +const CAROUSEL_INTERVAL_MS = 800; + +interface WelcomeCarouselProps { + /** Se true, il carosello Γ¨ in pausa */ + paused?: boolean; + /** Nome dell'utente da visualizzare (opzionale) */ + userName?: string; +} + +export function WelcomeCarousel({paused = false, userName}: WelcomeCarouselProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + + useEffect(() => { + if (paused) return; + + const interval = setInterval(() => { + setIsTransitioning(true); + + setTimeout(() => { + setCurrentIndex((prev) => (prev + 1) % WELCOME_MESSAGES.length); + setIsTransitioning(false); + }, 150); // Durata transizione fade + }, CAROUSEL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [paused]); + + const currentMessage = WELCOME_MESSAGES[currentIndex]; + + return ( +
+ {/* Messaggio di benvenuto */} +
+

+ {currentMessage.text} +

+

+ {currentMessage.lang.toUpperCase()} +

+
+ + {/* Nome utente */} + {userName && ( +
+

+ {userName} +

+
+ )} + + {/* Indicatori */} +
+ {WELCOME_MESSAGES.map((_, index) => ( +
+ ))} +
+
+ ); +} + +export default WelcomeCarousel; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6bb01ae..67eafa8 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,8 +1,10 @@ // 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'; +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'; +export {WelcomeCarousel} from './WelcomeCarousel'; +export {NumLockBanner} from './NumLockBanner'; diff --git a/frontend/src/hooks/useRFIDScanner.ts b/frontend/src/hooks/useRFIDScanner.ts index f33c093..f97a981 100644 --- a/frontend/src/hooks/useRFIDScanner.ts +++ b/frontend/src/hooks/useRFIDScanner.ts @@ -1,219 +1,335 @@ /** - * Focolari Voting System - RFID Scanner Hook + * Focolari Voting System - RFID Scanner Hook (v2 - Multi-Pattern) * * Questo hook gestisce la lettura di badge RFID tramite lettori USB - * che emulano una tastiera. Il protocollo prevede: - * - Carattere di inizio: `;` - * - Carattere di fine: `?` - * - Esempio: `;00012345?` + * che emulano una tastiera. Supporta pattern multipli per diversi layout: + * - Layout US: `;` β†’ `?` + * - Layout IT: `Γ²` β†’ `_` + * + * NOTA: Il lettore RFID invia Enter (\n) dopo l'ultimo carattere. + * L'hook lo gestisce ignorando l'Enter immediatamente dopo il completamento. * * L'hook funziona indipendentemente dal focus dell'applicazione. */ -import { useState, useEffect, useCallback, useRef } from 'react'; -import type { RFIDScannerState, RFIDScanResult } from '../types'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {RFIDScannerState, RFIDScanResult} from '../types'; -// Costanti -const START_SENTINEL = ';'; -const END_SENTINEL = '?'; -const TIMEOUT_MS = 3000; // 3 secondi di timeout +// ============================================ +// CONFIGURAZIONE PATTERN RFID +// ============================================ + +export interface RFIDPattern { + name: string; + start: string; + end: string; +} + +export const VALID_PATTERNS: RFIDPattern[] = [ + {name: 'US', start: ';', end: '?'}, + {name: 'IT', start: 'Γ²', end: '_'}, +]; + +const TIMEOUT_MS = 2500; // 2.5 secondi di timeout +const ENTER_GRACE_PERIOD_MS = 100; // Ignora Enter entro 100ms dal completamento + +// ============================================ +// LOGGING +// ============================================ + +const log = (message: string, ...args: unknown[]) => { + console.log(`[RFID] ${message}`, ...args); +}; + +const logWarn = (message: string, ...args: unknown[]) => { + console.warn(`[RFID] ${message}`, ...args); +}; + +// ============================================ +// TIPI +// ============================================ interface UseRFIDScannerOptions { - /** Callback chiamato quando un badge viene letto con successo */ - 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; + /** 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 interface UseRFIDScannerReturn { + /** Stato corrente dello scanner */ + state: RFIDScannerState; + /** Buffer corrente (solo per debug) */ + buffer: string; + /** Ultimo codice scansionato */ + lastScan: RFIDScanResult | null; + /** Pattern attualmente in uso (solo durante scanning) */ + activePattern: RFIDPattern | null; + /** Reset manuale dello scanner */ + reset: () => void; + /** Ultimi eventi tastiera (per debug) */ + keyLog: KeyLogEntry[]; } +export interface KeyLogEntry { + key: string; + code: string; + timestamp: number; +} + +const MAX_KEY_LOG = 20; + +// ============================================ +// HOOK +// ============================================ + export function useRFIDScanner({ - onScan, - onTimeout, - onScanStart, - preventDefaultOnScan = true, - disabled = false, -}: UseRFIDScannerOptions): UseRFIDScannerReturn { - const [state, setState] = useState('idle'); - const [buffer, setBuffer] = useState(''); - const [lastScan, setLastScan] = useState(null); + onScan, + onTimeout, + onScanStart, + preventDefaultOnScan = true, + disabled = false, + }: UseRFIDScannerOptions): UseRFIDScannerReturn { + const [state, setState] = useState('idle'); + const [buffer, setBuffer] = useState(''); + const [lastScan, setLastScan] = useState(null); + const [activePattern, setActivePattern] = useState(null); + const [keyLog, setKeyLog] = useState([]); - // Refs per mantenere i valori aggiornati nei callback - const bufferRef = useRef(''); - const stateRef = useRef('idle'); - const timeoutRef = useRef(null); + // Refs per mantenere i valori aggiornati nei callback + const bufferRef = useRef(''); + const stateRef = useRef('idle'); + const activePatternRef = useRef(null); + const timeoutRef = useRef(null); + const lastCompletionRef = useRef(0); - // Sync refs con state - useEffect(() => { - bufferRef.current = buffer; - }, [buffer]); + // Sync refs con state + useEffect(() => { + bufferRef.current = buffer; + }, [buffer]); - useEffect(() => { - stateRef.current = state; - }, [state]); + useEffect(() => { + stateRef.current = state; + }, [state]); - /** - * Pulisce il timeout attivo - */ - const clearScanTimeout = useCallback(() => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); + useEffect(() => { + activePatternRef.current = activePattern; + }, [activePattern]); - /** - * Resetta lo scanner allo stato idle - */ - const reset = useCallback(() => { - clearScanTimeout(); - setState('idle'); - setBuffer(''); - bufferRef.current = ''; - stateRef.current = 'idle'; - }, [clearScanTimeout]); + /** + * Aggiunge un evento al log tastiera + */ + const addKeyLog = useCallback((key: string, code: string) => { + setKeyLog(prev => { + const newEntry: KeyLogEntry = {key, code, timestamp: Date.now()}; + const updated = [newEntry, ...prev].slice(0, MAX_KEY_LOG); + return updated; + }); + }, []); - /** - * 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(); - } + /** + * Pulisce il timeout attivo + */ + const clearScanTimeout = useCallback(() => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; } - 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'); + /** + * Resetta lo scanner allo stato idle + */ + const reset = useCallback(() => { + clearScanTimeout(); + setState('idle'); + setBuffer(''); + setActivePattern(null); + bufferRef.current = ''; + stateRef.current = 'idle'; + activePatternRef.current = null; + }, [clearScanTimeout]); - if (preventDefaultOnScan) { - event.preventDefault(); - } + /** + * Avvia il timeout di sicurezza + */ + const startTimeout = useCallback(() => { + clearScanTimeout(); + timeoutRef.current = window.setTimeout(() => { + logWarn('Buffer timeout - clearing data'); + onTimeout?.(); + reset(); + }, TIMEOUT_MS); + }, [clearScanTimeout, onTimeout, reset]); - setState('scanning'); - setBuffer(''); - bufferRef.current = ''; - startTimeout(); - onScanStart?.(); - } - // Altrimenti ignora il tasto (comportamento normale) - return; - } + /** + * Trova il pattern che corrisponde al carattere start + */ + const findPatternByStart = useCallback((char: string): RFIDPattern | undefined => { + return VALID_PATTERNS.find(p => p.start === char); + }, []); - // STATO SCANNING: accumula i caratteri o termina - if (stateRef.current === 'scanning') { - if (preventDefaultOnScan) { - event.preventDefault(); + /** + * Handler principale per gli eventi keydown + */ + useEffect(() => { + if (disabled) { + return; } - if (key === END_SENTINEL) { - // Fine della scansione - clearScanTimeout(); + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key; + const code = event.code; - const scannedCode = bufferRef.current.trim(); + // Log per debug + addKeyLog(key, code); - if (scannedCode.length > 0) { - console.log('[RFID Scanner] Codice scansionato:', scannedCode); + // Gestione Enter dopo completamento (grace period) + if (key === 'Enter' && Date.now() - lastCompletionRef.current < ENTER_GRACE_PERIOD_MS) { + log('Enter ignorato (post-completion grace period)'); + if (preventDefaultOnScan) { + event.preventDefault(); + } + return; + } - const result: RFIDScanResult = { - code: scannedCode, - timestamp: Date.now(), - }; + // Gestione ESC: annulla scansione in corso + if (key === 'Escape' && stateRef.current === 'scanning') { + log('Scansione annullata con ESC'); + if (preventDefaultOnScan) { + event.preventDefault(); + } + reset(); + return; + } - setLastScan(result); - onScan(scannedCode); - } else { - console.warn('[RFID Scanner] Codice vuoto scartato'); - } + // Ignora tasti speciali (frecce, funzione, ecc.) ma non i sentinel + const isStartSentinel = VALID_PATTERNS.some(p => p.start === key); + const isEndSentinel = activePatternRef.current?.end === key; - 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; - } - } + if (key.length > 1 && !isStartSentinel && !isEndSentinel) { + // Eccezione per Backspace/Enter in stato scanning: ignora ma non resetta + if ((key === 'Backspace' || key === 'Enter') && stateRef.current === 'scanning') { + if (preventDefaultOnScan) { + event.preventDefault(); + } + } + return; + } + + // STATO IDLE: attende un carattere start di qualsiasi pattern + if (stateRef.current === 'idle') { + const pattern = findPatternByStart(key); + + if (pattern) { + log(`Start sentinel detected: '${key}' (pattern ${pattern.name})`); + + if (preventDefaultOnScan) { + event.preventDefault(); + } + + setState('scanning'); + setActivePattern(pattern); + setBuffer(''); + bufferRef.current = ''; + activePatternRef.current = pattern; + startTimeout(); + onScanStart?.(); + } + // Altrimenti ignora il tasto (comportamento normale) + return; + } + + // STATO SCANNING: accumula i caratteri o termina + if (stateRef.current === 'scanning') { + if (preventDefaultOnScan) { + event.preventDefault(); + } + + const currentPattern = activePatternRef.current; + + // Verifica se Γ¨ l'end sentinel del pattern attivo + if (currentPattern && key === currentPattern.end) { + // Fine della scansione + clearScanTimeout(); + lastCompletionRef.current = Date.now(); + + const scannedCode = bufferRef.current.trim(); + + if (scannedCode.length > 0) { + log(`Scan complete (pattern ${currentPattern.name}): ${scannedCode}`); + + const result: RFIDScanResult = { + code: scannedCode, + timestamp: Date.now(), + }; + + setLastScan(result); + onScan(scannedCode); + } else { + logWarn('Empty code discarded'); + } + + reset(); + } else if (findPatternByStart(key)) { + // Nuovo start sentinel durante scansione: resetta e ricomincia con nuovo pattern + const newPattern = findPatternByStart(key)!; + log(`New start sentinel during scan - switching to pattern ${newPattern.name}`); + setBuffer(''); + bufferRef.current = ''; + setActivePattern(newPattern); + activePatternRef.current = newPattern; + startTimeout(); + } else { + // Accumula il carattere nel buffer + const newBuffer = bufferRef.current + key; + setBuffer(newBuffer); + bufferRef.current = newBuffer; + } + } + }; + + // Aggiungi listener globale + window.addEventListener('keydown', handleKeyDown, {capture: true}); + + // Cleanup + return () => { + window.removeEventListener('keydown', handleKeyDown, {capture: true}); + clearScanTimeout(); + }; + }, [ + disabled, + onScan, + onScanStart, + preventDefaultOnScan, + clearScanTimeout, + reset, + startTimeout, + findPatternByStart, + addKeyLog, + ]); + + // Cleanup al unmount + useEffect(() => { + return () => { + clearScanTimeout(); + }; + }, [clearScanTimeout]); + + return { + state, + buffer, + lastScan, + activePattern, + reset, + keyLog, }; - - // Aggiungi listener globale - window.addEventListener('keydown', handleKeyDown, { capture: true }); - - // Cleanup - return () => { - window.removeEventListener('keydown', handleKeyDown, { capture: true }); - clearScanTimeout(); - }; - }, [ - disabled, - onScan, - onScanStart, - preventDefaultOnScan, - clearScanTimeout, - reset, - startTimeout, - ]); - - // Cleanup al unmount - useEffect(() => { - return () => { - clearScanTimeout(); - }; - }, [clearScanTimeout]); - - return { - state, - buffer, - lastScan, - reset, - }; } export default useRFIDScanner; diff --git a/frontend/src/index.css b/frontend/src/index.css index fd1cbc5..de55260 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,25 +5,25 @@ ============================================ */ @theme { - /* Colori Istituzionali */ - --color-focolare-blue: #0072CE; - --color-focolare-blue-dark: #005BA1; - --color-focolare-blue-light: #3D9BE0; + /* 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-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; + --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; + /* Stati */ + --color-success: #22C55E; + --color-success-dark: #16A34A; + --color-error: #EF4444; + --color-error-dark: #DC2626; + --color-warning: #F59E0B; } /* ============================================ @@ -31,25 +31,25 @@ ============================================ */ l:root { - font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; - line-height: 1.5; - font-weight: 400; - color-scheme: light; + 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%); + 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; + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; } /* ============================================ @@ -58,14 +58,14 @@ html, body { button, .touch-target { - min-height: 48px; - min-width: 48px; - touch-action: manipulation; - -webkit-tap-highlight-color: transparent; + min-height: 48px; + min-width: 48px; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; } input { - font-size: 16px; /* Previene zoom su iOS */ + font-size: 16px; /* Previene zoom su iOS */ } /* ============================================ @@ -73,70 +73,70 @@ input { ============================================ */ @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); - } + 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); - } + 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; - } + 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); - } + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { + opacity: 0; + } + to { + opacity: 1; + } } .animate-pulse-glow { - animation: pulse-glow 2s ease-in-out infinite; + animation: pulse-glow 2s ease-in-out infinite; } .animate-pulse-error { - animation: pulse-error 1s ease-in-out infinite; + animation: pulse-error 1s ease-in-out infinite; } .animate-blink { - animation: blink 1.5s step-start infinite; + animation: blink 1.5s step-start infinite; } .animate-slide-up { - animation: slide-up 0.3s ease-out; + animation: slide-up 0.3s ease-out; } .animate-fade-in { - animation: fade-in 0.3s ease-out; + animation: fade-in 0.3s ease-out; } /* ============================================ @@ -144,11 +144,11 @@ input { ============================================ */ .text-shadow { - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + 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); + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..dfa54f5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,21 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' +import {StrictMode} from 'react' +import {createRoot} from 'react-dom/client' +import {BrowserRouter, Route, Routes} from 'react-router-dom' import './index.css' import App from './App.tsx' +import {DebugScreen} from './screens' + +function DebugWrapper() { + return window.location.href = '/'}/> +} createRoot(document.getElementById('root')!).render( - - - , + + + + }/> + }/> + + + , ) diff --git a/frontend/src/screens/ActiveGateScreen.tsx b/frontend/src/screens/ActiveGateScreen.tsx index 3cedcf4..e89f426 100644 --- a/frontend/src/screens/ActiveGateScreen.tsx +++ b/frontend/src/screens/ActiveGateScreen.tsx @@ -3,211 +3,293 @@ * Schermata principale del varco attivo */ -import { Logo, Button, RFIDStatus, UserCard, CountdownTimer } from '../components'; -import type { RoomInfo, User, RFIDScannerState } from '../types'; +import {useEffect, useState} from 'react'; +import {Button, CountdownTimer, Logo, NumLockBanner, RFIDStatus, UserCard} from '../components'; +import type {RFIDScannerState, RoomInfo, User} from '../types'; + +// Timeout per badge non trovato (30 secondi) +const NOT_FOUND_TIMEOUT_SECONDS = 30; interface ActiveGateScreenProps { - roomInfo: RoomInfo; - rfidState: RFIDScannerState; - rfidBuffer: string; - currentUser: User | null; - loading: boolean; - error?: string; - onCancelUser: () => void; - onLogout: () => void; - userTimeoutSeconds?: number; - onUserTimeout: () => void; + roomInfo: RoomInfo; + rfidState: RFIDScannerState; + rfidBuffer: string; + currentUser: User | null; + notFoundBadge: string | null; + loading: boolean; + error?: string; + onCancelUser: () => void; + onLogout: () => void; + userTimeoutSeconds?: number; + onUserTimeout: () => void; + showValidatorBadgeNotice?: boolean; } export function ActiveGateScreen({ - roomInfo, - rfidState, - rfidBuffer, - currentUser, - loading, - error, - onCancelUser, - onLogout, - userTimeoutSeconds = 60, - onUserTimeout, -}: ActiveGateScreenProps) { - return ( -
- {/* Header */} -
- -
-
-

{roomInfo.room_name}

-

ID: {roomInfo.meeting_id}

-
- -
-
+ roomInfo, + rfidState, + rfidBuffer, + currentUser, + notFoundBadge, + loading, + error, + onCancelUser, + onLogout, + userTimeoutSeconds = 60, + onUserTimeout, + showValidatorBadgeNotice = false, + }: ActiveGateScreenProps) { + // Timer per countdown badge non trovato + const [notFoundCountdown, setNotFoundCountdown] = useState(NOT_FOUND_TIMEOUT_SECONDS); - {/* Main Content */} -
- {loading ? ( - // Loading state -
-
- - - - -
-

Caricamento dati...

-
- ) : error ? ( - // Error state -
-
- - - -
-

{error}

- -
- ) : currentUser ? ( - // User found - Decision screen -
-
- {/* Timer bar */} -
- -
+ // Reset countdown quando cambia notFoundBadge + useEffect(() => { + if (notFoundBadge) { + setNotFoundCountdown(NOT_FOUND_TIMEOUT_SECONDS); + const interval = setInterval(() => { + setNotFoundCountdown(prev => { + if (prev <= 1) { + clearInterval(interval); + onUserTimeout(); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + } + }, [notFoundBadge, onUserTimeout]); - {/* User Card */} - + return ( +
+ {/* Header */} +
+ +
+
+

{roomInfo.room_name}

+

ID: {roomInfo.meeting_id}

+
+ +
+
- {/* Action Hint */} -
- {currentUser.ammesso ? ( -
-

- βœ“ Utente ammesso all'ingresso -

-

- Passa il badge VALIDATORE per confermare l'accesso -

-
+ {/* Notifica Badge Validatore Ignorato */} + {showValidatorBadgeNotice && ( +
+
+

Badge validatore rilevato

+

Se il validatore Γ¨ cambiato, esci e rilogga con il nuovo badge.

+
+
+ )} + + {/* Main Content */} +
+ {loading ? ( + // Loading state +
+
+ + + + +
+

Caricamento dati...

+
+ ) : notFoundBadge ? ( + // Badge non trovato +
+
+ + + +
+

Utente con badge:

+

{notFoundBadge}

+

non trovato nel sistema

+ + {/* Footer con countdown */} +
+

+ Ritorno all'attesa in {notFoundCountdown} secondi +

+
+
+ ) : error ? ( + // Error state +
+
+ + + +
+

{error}

+ +
+ ) : currentUser ? ( + // User found - Decision screen +
+
+ {/* Timer bar */} +
+ +
+ + {/* User Card */} + + + {/* Action Hint */} +
+ {currentUser.ammesso ? ( +
+

+ βœ“ Utente ammesso all'ingresso +

+

+ Passa il badge VALIDATORE per + confermare l'accesso +

+
+ ) : ( +
+

+ ⚠️ ACCESSO NON CONSENTITO +

+

+ Questo utente non Γ¨ autorizzato ad entrare +

+
+ )} +
+ + {/* Cancel Button */} +
+ +
+
+
) : ( -
-

- ⚠️ ACCESSO NON CONSENTITO -

-

- Questo utente non Γ¨ autorizzato ad entrare -

-
- )} -
+ // Idle - Waiting for participant +
+
+ + + +
- {/* Cancel Button */} -
- -
-
-
- ) : ( - // Idle - Waiting for participant -
-
- - - -
+

+ Varco Attivo +

-

- Varco Attivo -

+

+ In attesa del partecipante... +

-

- In attesa del partecipante... -

- -
-
- - - - +
+
+ + + + Passa il badge -
-
-
- )} -
+
+
- {/* Footer with RFID Status */} -
- - + {/* Banner NumLock per desktop */} + +
+ )} + + + {/* Footer with RFID Status */} +
+ + Varco attivo β€’ {new Date().toLocaleTimeString('it-IT')} -
-
- ); + +
+ ); } export default ActiveGateScreen; diff --git a/frontend/src/screens/DebugScreen.tsx b/frontend/src/screens/DebugScreen.tsx new file mode 100644 index 0000000..62da600 --- /dev/null +++ b/frontend/src/screens/DebugScreen.tsx @@ -0,0 +1,208 @@ +/** + * Debug Screen - Focolari Voting System + * + * Pagina diagnostica per debug del lettore RFID. + * Mostra in tempo reale gli eventi tastiera e lo stato dello scanner. + */ + +import {useRFIDScanner, VALID_PATTERNS} from '../hooks/useRFIDScanner'; +import {Button, Logo} from '../components'; + +interface DebugScreenProps { + onBack: () => void; +} + +export function DebugScreen({onBack}: DebugScreenProps) { + const { + state, + buffer, + lastScan, + activePattern, + keyLog, + reset, + } = useRFIDScanner({ + onScan: (code) => { + console.log('[DEBUG] Scan received:', code); + }, + onTimeout: () => { + console.log('[DEBUG] Timeout occurred'); + }, + }); + + const formatTimestamp = (ts: number) => { + const date = new Date(ts); + return date.toLocaleTimeString('it-IT', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }); + }; + + return ( +
+ {/* Header */} +
+
+ +

Debug RFID

+
+ +
+ +
+ {/* Scanner Status */} +
+

+ πŸ“‘ Stato Scanner +

+ +
+ {/* State */} +
+ Stato + + {state.toUpperCase()} + +
+ + {/* Active Pattern */} +
+ Pattern Attivo + + {activePattern + ? `${activePattern.name} (${activePattern.start} β†’ ${activePattern.end})` + : 'β€”' + } + +
+ + {/* Buffer */} +
+ Buffer Corrente +
+ {buffer || (vuoto)} +
+
+ + {/* Last Scan */} +
+ Ultimo Codice Rilevato + {lastScan ? ( +
+

{lastScan.code}

+

+ {formatTimestamp(lastScan.timestamp)} +

+
+ ) : ( +
+ Nessuna scansione +
+ )} +
+ + {/* Reset Button */} + +
+
+ + {/* Key Log */} +
+

+ ⌨️ Log Tastiera (ultimi 20) +

+ +
+
+ Ora + Key + Code +
+ +
+ {keyLog.length === 0 ? ( +
+ Premi un tasto per vedere i log... +
+ ) : ( + keyLog.map((entry, index) => { + const isStartSentinel = VALID_PATTERNS.some(p => p.start === entry.key); + const isEndSentinel = VALID_PATTERNS.some(p => p.end === entry.key); + + let rowClass = 'bg-slate-900'; + if (isStartSentinel) rowClass = 'bg-green-900/30'; + if (isEndSentinel) rowClass = 'bg-blue-900/30'; + + return ( +
+ + {formatTimestamp(entry.timestamp)} + + + {entry.key === ' ' ? '(space)' : + entry.key === 'Enter' ? '↡ Enter' : + entry.key} + + + {entry.code} + +
+ ); + }) + )} +
+
+
+ + {/* Pattern Reference */} +
+

+ πŸ“‹ Pattern Supportati +

+ +
+ {VALID_PATTERNS.map((pattern) => ( +
+

{pattern.name}

+
+ {pattern.start} + β†’ + {pattern.end} +
+

+ Esempio: {pattern.start}123456{pattern.end} +

+
+ ))} +
+ +

+ πŸ’‘ Nota: Il lettore RFID invia anche Enter (↡) dopo l'ultimo carattere. + L'hook lo gestisce automaticamente. +

+
+
+
+ ); +} + +export default DebugScreen; diff --git a/frontend/src/screens/ErrorModal.tsx b/frontend/src/screens/ErrorModal.tsx index ea3762c..4e0b4f1 100644 --- a/frontend/src/screens/ErrorModal.tsx +++ b/frontend/src/screens/ErrorModal.tsx @@ -3,70 +3,71 @@ * Modal per errori */ -import { Modal, Button } from '../components'; +import {Button, Modal} from '../components'; interface ErrorModalProps { - isOpen: boolean; - onClose: () => void; - title?: string; - message: string; + isOpen: boolean; + onClose: () => void; + title?: string; + message: string; } export function ErrorModal({ - isOpen, - onClose, - title = 'Errore', - message -}: ErrorModalProps) { - return ( - -
- {/* Error Icon */} -
-
- - - -
-
- - {/* Title */} -

- {title} -

- - {/* Error Message */} -

- {message} -

- - {/* Close Button */} - -
-
- ); +
+ {/* Error Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {title} +

+ + {/* Error Message */} +

+ {message} +

+ + {/* Close Button */} + +
+ + ); } export default ErrorModal; diff --git a/frontend/src/screens/LoadingScreen.tsx b/frontend/src/screens/LoadingScreen.tsx index b93bca4..e26b390 100644 --- a/frontend/src/screens/LoadingScreen.tsx +++ b/frontend/src/screens/LoadingScreen.tsx @@ -2,89 +2,153 @@ * Loading Screen - Focolari Voting System */ -import { Logo } from '../components'; +import {useCallback, useEffect, useState} from 'react'; +import {Logo} from '../components'; +import {checkServerHealth} from '../services/api'; interface LoadingScreenProps { - message?: string; - error?: string; - onRetry?: () => void; + message?: string; + error?: string; + onRetry?: () => void; } export function LoadingScreen({ - message = 'Connessione al server...', - error, - onRetry -}: LoadingScreenProps) { - return ( -
-
- + message = 'Connessione al server...', + error, + onRetry + }: LoadingScreenProps) { + const [isRetrying, setIsRetrying] = useState(false); + const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking'); -

- Focolari Voting System -

+ // Ping automatico quando c'Γ¨ un errore + useEffect(() => { + if (!error) return; - {!error ? ( - <> -
-
- - - - -
+ const checkServer = async () => { + setServerStatus('checking'); + const isOnline = await checkServerHealth(); + setServerStatus(isOnline ? 'online' : 'offline'); + }; + + checkServer(); + + // Ripeti il ping ogni 3 secondi + const interval = setInterval(checkServer, 3000); + return () => clearInterval(interval); + }, [error]); + + const handleRetry = useCallback(async () => { + if (!onRetry) return; + + setIsRetrying(true); + const isOnline = await checkServerHealth(); + + if (isOnline) { + onRetry(); + } else { + setServerStatus('offline'); + } + setIsRetrying(false); + }, [onRetry]); + + // Se il server torna online, riprova automaticamente + useEffect(() => { + if (serverStatus === 'online' && error && onRetry) { + console.log('[FLOW] Server tornato online, ricarico...'); + onRetry(); + } + }, [serverStatus, error, onRetry]); + + return ( +
+
+ + +

+ Focolari Voting System +

+ + {!error ? ( + <> +
+
+ + + + +
+
+

{message}

+ + ) : ( + <> +
+
+ + + +
+
+ +

{error}

+ + {/* Server status indicator */} +
+ + + {serverStatus === 'checking' && 'Verifica connessione...'} + {serverStatus === 'online' && 'Server raggiungibile - riconnessione...'} + {serverStatus === 'offline' && 'Server non raggiungibile'} + +
+ + {onRetry && ( + + )} + + )}
-

{message}

- - ) : ( - <> -
-
- - - -
-
-

{error}

- {onRetry && ( - - )} - - )} -
-
- ); +
+ ); } export default LoadingScreen; diff --git a/frontend/src/screens/SuccessModal.tsx b/frontend/src/screens/SuccessModal.tsx index d7d8f4c..bafe05a 100644 --- a/frontend/src/screens/SuccessModal.tsx +++ b/frontend/src/screens/SuccessModal.tsx @@ -1,88 +1,78 @@ /** * Success Modal - Focolari Voting System - * Modal fullscreen per conferma ingresso + * Modal fullscreen per conferma ingresso con carosello multilingua */ -import { Modal } from '../components'; +import {Modal, WelcomeCarousel} from '../components'; interface SuccessModalProps { - isOpen: boolean; - onClose: () => void; - welcomeMessage: string; - userName?: string; + isOpen: boolean; + onClose: () => void; + userName?: string; } export function SuccessModal({ - isOpen, - onClose, - welcomeMessage, - userName -}: SuccessModalProps) { - return ( - -
- {/* Success Icon */} -
-
- - - -
-
+ isOpen, + onClose, + userName + }: SuccessModalProps) { + return ( + +
+ {/* Success Icon */} +
+
+ + + +
+
- {/* User Name */} - {userName && ( -

- {userName} -

- )} + {/* Carosello Messaggi Benvenuto */} + - {/* Welcome Message */} -

- {welcomeMessage} -

+ {/* Sub text */} +

+ Ingresso registrato con successo +

- {/* Sub text */} -

- Ingresso registrato con successo -

- - {/* Auto-close indicator */} -
-
-
-
- -
-
- - ); +
+
+
+ ); } export default SuccessModal; diff --git a/frontend/src/screens/ValidatorLoginScreen.tsx b/frontend/src/screens/ValidatorLoginScreen.tsx index 118ae2d..234b4aa 100644 --- a/frontend/src/screens/ValidatorLoginScreen.tsx +++ b/frontend/src/screens/ValidatorLoginScreen.tsx @@ -2,178 +2,193 @@ * Validator Login Screen - Focolari Voting System */ -import { useState, useRef, useEffect } from 'react'; -import { Logo, Button, Input, RFIDStatus } from '../components'; -import type { RoomInfo, RFIDScannerState } from '../types'; +import {useEffect, useRef, useState} from 'react'; +import {Button, Input, Logo, NumLockBanner, RFIDStatus} from '../components'; +import type {RFIDScannerState, RoomInfo} from '../types'; interface ValidatorLoginScreenProps { - roomInfo: RoomInfo; - rfidState: RFIDScannerState; - rfidBuffer: string; - validatorBadge: string | null; - onPasswordSubmit: (password: string) => void; - onCancel: () => void; - error?: string; - loading?: boolean; + 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(null); + roomInfo, + rfidState, + rfidBuffer, + validatorBadge, + onPasswordSubmit, + onCancel, + error, + loading = false, + }: ValidatorLoginScreenProps) { + const [password, setPassword] = useState(''); + const inputRef = useRef(null); - // Focus input quando appare il form password - useEffect(() => { - if (validatorBadge && inputRef.current) { - inputRef.current.focus(); - } - }, [validatorBadge]); + // 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); - } - }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (password.trim()) { + onPasswordSubmit(password); + } + }; - return ( -
- {/* Header */} -
- -
-

{roomInfo.room_name}

-

ID: {roomInfo.meeting_id}

-
-
- - {/* Main Content */} -
-
- {!validatorBadge ? ( - // Attesa badge validatore - <> -
-
- - - + return ( +
+ {/* Header */} +
+ +
+

{roomInfo.room_name}

+

ID: {roomInfo.meeting_id}

+
-

- Accesso Varco -

+ {/* Main Content */} +
+
+ {!validatorBadge ? ( + // Attesa badge validatore + <> +
+
+ + + +
-

- Passa il badge del Validatore per iniziare -

+

+ Accesso Varco +

-
-
- - - - +

+ Passa il badge del Validatore per iniziare +

+ +
+
+ + + + In attesa del badge... -
-
-
- - ) : ( - // Form password - <> -
-
- - - -
+
+
-

- Badge Riconosciuto -

-

- Badge: {validatorBadge} -

-
+ {/* Banner NumLock per desktop */} + -
- setPassword(e.target.value)} - error={error} - autoComplete="off" - disabled={loading} - /> + {/* Messaggio errore */} + {error && ( +
+

{error}

+
+ )} +
+ + ) : ( + // Form password + <> +
+
+ + + +
-
- - +

+ Badge Riconosciuto +

+

+ Badge: {validatorBadge} +

+
+ + + setPassword(e.target.value)} + error={error} + autoComplete="off" + disabled={loading} + /> + +
+ + +
+ + + )}
- - - )} +
+ + {/* Footer with RFID Status */} +
+ +
-
- - {/* Footer with RFID Status */} -
- -
-
- ); + ); } export default ValidatorLoginScreen; diff --git a/frontend/src/screens/index.ts b/frontend/src/screens/index.ts index cdbb63d..f9aca5d 100644 --- a/frontend/src/screens/index.ts +++ b/frontend/src/screens/index.ts @@ -1,6 +1,7 @@ // Screens barrel export -export { LoadingScreen } from './LoadingScreen'; -export { ValidatorLoginScreen } from './ValidatorLoginScreen'; -export { ActiveGateScreen } from './ActiveGateScreen'; -export { SuccessModal } from './SuccessModal'; -export { ErrorModal } from './ErrorModal'; +export {LoadingScreen} from './LoadingScreen'; +export {ValidatorLoginScreen} from './ValidatorLoginScreen'; +export {ActiveGateScreen} from './ActiveGateScreen'; +export {SuccessModal} from './SuccessModal'; +export {ErrorModal} from './ErrorModal'; +export {DebugScreen} from './DebugScreen'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e65bb26..63cd184 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,67 +2,84 @@ * Focolari Voting System - API Service */ -import type { - RoomInfo, - User, - LoginRequest, - LoginResponse, - EntryRequest, - EntryResponse -} from '../types'; +import type {EntryRequest, EntryResponse, LoginRequest, LoginResponse, RoomInfo, User} from '../types'; -const API_BASE_URL = 'http://localhost:8000'; +// Path relativi: funziona sia in dev (proxy Vite) che in produzione (stesso server) +const API_BASE_URL = ''; + +// ============================================ +// LOGGING +// ============================================ + +const log = (message: string, ...args: unknown[]) => { + console.log(`[API] ${message}`, ...args); +}; + +const logError = (message: string, ...args: unknown[]) => { + console.error(`[API] ${message}`, ...args); +}; /** * Custom error class for API errors */ export class ApiError extends Error { - constructor( - message: string, - public statusCode: number, - public detail?: string - ) { - super(message); - this.name = 'ApiError'; - } + public statusCode: number; + public detail?: string; + + constructor( + message: string, + statusCode: number, + detail?: string + ) { + super(message); + this.name = 'ApiError'; + this.statusCode = statusCode; + this.detail = detail; + } } /** * Generic fetch wrapper with error handling */ async function apiFetch( - endpoint: string, - options?: RequestInit + endpoint: string, + options?: RequestInit ): Promise { - try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); + log(`Fetching ${options?.method || 'GET'} ${endpoint}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new ApiError( - errorData.detail || `HTTP Error ${response.status}`, - response.status, - errorData.detail - ); - } + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); - return await response.json(); - } catch (error) { - if (error instanceof ApiError) { - throw error; + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + logError(`Error ${response.status}: ${errorData.detail || 'Unknown error'}`); + throw new ApiError( + errorData.detail || `HTTP Error ${response.status}`, + response.status, + errorData.detail + ); + } + + const data = await response.json(); + log(`Response OK from ${endpoint}`); + return data; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + logError('Connection error:', error); + throw new ApiError( + 'Errore di connessione al server', + 0, + 'Verifica che il server sia attivo' + ); } - throw new ApiError( - 'Errore di connessione al server', - 0, - 'Verifica che il server sia attivo' - ); - } } // ============================================ @@ -74,7 +91,7 @@ async function apiFetch( * Ottiene le informazioni sulla sala e la riunione */ export async function getRoomInfo(): Promise { - return apiFetch('/info-room'); + return apiFetch('/info-room'); } /** @@ -82,14 +99,15 @@ export async function getRoomInfo(): Promise { * Autentica il validatore con badge e password */ export async function loginValidator( - badge: string, - password: string + badge: string, + password: string ): Promise { - const payload: LoginRequest = { badge, password }; - return apiFetch('/login-validate', { - method: 'POST', - body: JSON.stringify(payload), - }); + log(`Login attempt for badge: ${badge}`); + const payload: LoginRequest = {badge, password}; + return apiFetch('/login-validate', { + method: 'POST', + body: JSON.stringify(payload), + }); } /** @@ -97,7 +115,8 @@ export async function loginValidator( * Ottiene i dati anagrafici di un utente tramite badge */ export async function getUserByBadge(badgeCode: string): Promise { - return apiFetch(`/anagrafica/${encodeURIComponent(badgeCode)}`); + log(`Fetching anagrafica for badge: ${badgeCode}`); + return apiFetch(`/anagrafica/${encodeURIComponent(badgeCode)}`); } /** @@ -105,17 +124,18 @@ export async function getUserByBadge(badgeCode: string): Promise { * Registra l'ingresso di un utente */ export async function requestEntry( - userBadge: string, - validatorPassword: string + userBadge: string, + validatorPassword: string ): Promise { - const payload: EntryRequest = { - user_badge: userBadge, - validator_password: validatorPassword, - }; - return apiFetch('/entry-request', { - method: 'POST', - body: JSON.stringify(payload), - }); + log(`Entry request for badge: ${userBadge}`); + const payload: EntryRequest = { + user_badge: userBadge, + validator_password: validatorPassword, + }; + return apiFetch('/entry-request', { + method: 'POST', + body: JSON.stringify(payload), + }); } // ============================================ @@ -126,10 +146,10 @@ export async function requestEntry( * Check if the API server is reachable */ export async function checkServerHealth(): Promise { - try { - const response = await fetch(`${API_BASE_URL}/`); - return response.ok; - } catch { - return false; - } + try { + const response = await fetch(`${API_BASE_URL}/`); + return response.ok; + } catch { + return false; + } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 800afbc..a931c06 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,101 +1,103 @@ /** -} - variant?: 'success' | 'error' | 'info'; - children: React.ReactNode; - onClose?: () => void; - isOpen: boolean; -export interface ModalProps { - -} - paused?: boolean; - onExpire: () => void; - seconds: number; -export interface TimerProps { - -} - showWarning?: boolean; - user: User; -export interface UserCardProps { - -// ============================================ -// Component Props Types -// ============================================ - -} - timestamp: number; - code: string; -export interface RFIDScanResult { - -export type RFIDScannerState = 'idle' | 'scanning'; - -// ============================================ -// RFID Scanner Types -// ============================================ - -} - expiresAt: number; - loginTime: number; - token: string; - badge: string; -export interface ValidatorSession { - - | 'entry-error'; - | 'entry-success' - | 'showing-user' - | 'gate-active' - | 'validator-password' - | 'waiting-validator' - | 'loading' -export type AppState = - -// ============================================ -// Application State Types -// ============================================ - -} - validator_password: string; - user_badge: string; -export interface EntryRequest { - -} - password: string; - badge: string; -export interface LoginRequest { - -// ============================================ -// Request Types -// ============================================ - -} - welcome_message?: string; - message: string; - success: boolean; -export interface EntryResponse { - -} - token?: string; - message: string; - success: boolean; -export interface LoginResponse { - -} - warning?: string; - ammesso: boolean; - ruolo: 'Tecnico' | 'Votante' | 'Ospite'; - url_foto: string; - cognome: string; - nome: string; - badge_code: string; -export interface User { - -} - meeting_id: string; - room_name: string; -export interface RoomInfo { + * Focolari Voting System - TypeScript Types + */ // ============================================ // API Response Types // ============================================ - */ - * Focolari Voting System - TypeScript Types +export interface RoomInfo { + room_name: string; + meeting_id: string; + server_start_time: number; +} + +export interface User { + badge_code: string; + nome: string; + cognome: string; + url_foto: string; + ruolo: 'Tecnico' | 'Votante' | 'Ospite'; + ammesso: boolean; + warning?: string; +} + +export interface LoginResponse { + success: boolean; + message: string; + token?: string; +} + +export interface EntryResponse { + success: boolean; + message: string; +} + +// ============================================ +// Request Types +// ============================================ + +export interface LoginRequest { + badge: string; + password: string; +} + +export interface EntryRequest { + user_badge: string; + validator_password: string; +} + +// ============================================ +// Application State Types +// ============================================ + +export type AppState = + | 'loading' + | 'waiting-validator' + | 'validator-password' + | 'gate-active' + | 'showing-user' + | 'entry-success' + | 'entry-error'; + +export interface ValidatorSession { + badge: string; + password: string; + token: string; + loginTime: number; + expiresAt: number; + serverStartTime: number; // Per invalidare se il server riparte +} + +// ============================================ +// RFID Scanner Types +// ============================================ + +export type RFIDScannerState = 'idle' | 'scanning'; + +export interface RFIDScanResult { + code: string; + timestamp: number; +} + +// ============================================ +// Component Props Types +// ============================================ + +export interface UserCardProps { + user: User; + showWarning?: boolean; +} + +export interface TimerProps { + seconds: number; + onExpire: () => void; + paused?: boolean; +} + +export interface ModalProps { + isOpen: boolean; + onClose?: () => void; + children: React.ReactNode; + variant?: 'success' | 'error' | 'info'; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 6a27166..72ab8f4 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,19 +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', - } - } + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + focolari: { + blue: '#0072CE', + orange: '#F5A623', + yellow: '#FFD700', + } + } + }, }, - }, - plugins: [], + plugins: [], } \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index a9b5a59..2098166 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -3,11 +3,16 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", - "types": ["vite/client"], + "types": [ + "vite/client" + ], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -15,7 +20,6 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, @@ -24,5 +28,7 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef60..ea9d0cd 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "files": [], "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } ] } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8a67f62..d030a8b 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -2,18 +2,20 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2023", - "lib": ["ES2023"], + "lib": [ + "ES2023" + ], "module": "ESNext", - "types": ["node"], + "types": [ + "node" + ], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - /* Linting */ "strict": true, "noUnusedLocals": true, @@ -22,5 +24,7 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": [ + "vite.config.ts" + ] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c4069b7..2fdd27d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,40 @@ -import { defineConfig } from 'vite' +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()], + plugins: [react(), tailwindcss()], + + // Path relativi per funzionare su qualsiasi IP/porta + base: './', + + // Build nella cartella dist del frontend + build: { + outDir: 'dist', + emptyOutDir: true, + }, + + // Proxy API in sviluppo verso il backend (porta 8000) + // In produzione il backend serve direttamente il frontend + server: { + proxy: { + '/info-room': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + '/login-validate': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + '/anagrafica': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + '/entry-request': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, })