# Mini-spec UX — Portale fornitore + Editor lato azienda (§6) > Deliverable agente design 2026-05-31. Chiude i finding UX F1–F25 di [[DESIGN_MODULO_QUESTIONARI_FORNITORI]] §12. Copy + comportamento + acceptance criteria. > Utente di riferimento del portale: referente PMI fornitrice, NON tecnico, da smartphone, 1 volta ogni 6-12 mesi, senza supporto IT. Ogni scelta privilegia chiarezza, target ≥44px, recupero da errore sempre possibile. > Convenzioni riusate (verificate): `--primary:#06B6D4`; progress bar di `assessment.html` (`updateProgressBar`, id `progress-pct/-bar-fill/-answered/-total`, i18n `assessment.progress`); `debounce`, `showNotification`, `_loadFirmBranding()` (GET `/api/branding/current`), `initIdleTimeout()` overlay countdown — in `common.js`; badge `badge-success|warning|danger|neutral`; `sendSupplierQuestionnaire`+prompt rigenera-link in `supply-chain.html`; baseline + footer GDPR + `noindex,nofollow` in `supplier-assessment.html`. > Endpoint (forma router §12): `POST /api/supplier-portal/request-otp`, `verify-otp`, `GET /me`, `GET /{id}`, `POST /{id}/submit`, `PATCH /{id}/answers`. > i18n: namespace `sp.*` (+ `sp.editor.*`) in `i18n.js`; ogni stringa IT ha controparte EN. ## 1. Portale fornitore — supplier-portal.html Principi: una colonna max 560px, branding del **committente** in header (NIS2 Agile solo footer), zero gergo ("codice"/"link"/"questionario", mai "token/OTP/JWT/campaign"), selettore lingua IT/EN in alto a destra, `noindex,nofollow`, nessun link all'app interna. Stati: 1. **Loading**: spinner; se `GET /me`/branding >8s → stato errore rete. 2. **Richiesta accesso**: input email (`type=email inputmode=email autocomplete=email autofocus`, label visibile), CTA "Invia link di accesso", micro-copy GDPR con link informativa. **Risposta sempre opaca** (anti-enumerazione) → va sempre allo stato "email inviata". 3. **Email inviata**: "Controlla la tua email" + avviso spam + mittente; campo OTP fallback; **reinvio con countdown 60s** (raddoppia dal 3° invio); via d'uscita "Usa un'altra email". 4. **Verifica OTP — errori specifici** (F2/F3): messaggi umani con via d'uscita per: codice errato ("…hai ancora {n} tentativi", contatore reale da `supplier_otp.attempts`), scaduto ("→ Invia un nuovo codice"), lockout ("bloccato per {minuti} min", persistente lato DB), link già usato ("→ Invia un nuovo link"), errore rete ("Riprova"). Mai codici HTTP/"token". 5. **Gerarchia magic-link/OTP** (F1/F4): nel portale e nell'email il **pulsante magic-link è primario**, OTP fallback. **No doppio-consumo**: l'apertura del link (prefetch mail mobile) NON consuma il token; consumo solo al click esplicito "Entra nel portale". AC: aprire 2× e poi cliccare deve funzionare. 6. **Dashboard**: se **1 solo questionario aperto** → vai diretto alla compilazione. Se multipli → card con badge stato + scadenza in chiaro + CTA "Compila/Riprendi/Vedi ricevuta"; ordinamento da-compilare prima per scadenza. 7. **Compilazione**: header con badge scadenza sticky + progress bar + domande single-page (vedi §4). 8. **Vuoto**: "Nessun questionario in attesa" + suggerimento contattare committente. 9. **Completato/Ricevuta**: "Questionario inviato. Grazie!" + "Scarica PDF" + (Caso B) "Torna ai questionari". **Score mostrato solo se `show_score_to_supplier=true`** (default OFF, F23) → rimuovere/condizionare lo score sempre-mostrato dell'attuale supplier-assessment.html. 10. **Errore rete**: non distruttivo, risposte salvate localmente, "Riprova". 11. **Sessione in scadenza (4h)**: overlay countdown (riuso `initIdleTimeout`), "Continua" estende; alla scadenza "Le tue risposte sono salvate. Richiedi un nuovo link per riprendere". Ripresa dall'ultima domanda dopo nuovo OTP. ## 2. Autosave (F11 — priorità assoluta) - Stato `in_progress` alla prima risposta. Salvataggio per-domanda via `PATCH /{id}/answers` con `debounce(fn,800)`. - **Doppio livello**: localStorage immediato (resilienza offline) + PATCH server (cross-device). Server = source of truth; rinvia PATCH falliti. - Indicatore discreto: "Salvataggio…" / "Bozza salvata {ora}" / "Salvataggio in sospeso — riproveremo". - **Ripresa**: banner "Bentornato. Hai risposto a {x} di {y} domande" + "Riprendi" → scroll alla prima domanda senza risposta. - **"Salva bozza"** (ghost, ridondante ma rassicurante) vs **"Invia definitivo"** (CTA primaria, conferma in modale "dopo l'invio non potrai modificare", disabilitata finché le obbligatorie non sono complete; se mancano → scroll alla prima + "{k} risposte obbligatorie mancanti"). - AC: chiudere/riaprire (anche altro device dopo nuovo OTP) → risposte presenti, cursore sull'ultima non risposta; submit blocca PATCH successivi (409 "già inviato"). ## 3. Email invito/OTP (F6) Inviata via template `/api/emails/send` (NON `send-raw` → OTP fuori da `email_log`). Branding committente, **una sola CTA**. Contenuto minimo: (1) oggetto con nome committente; (2) motivo 1 frase ("Come fornitore di {Committente}…richiesto dalla normativa NIS2"); (3) tempo stimato (~{n} min); (4) scadenza in chiaro (solo se `due_at`); (5) CTA singola magic-link "Compila il questionario"; (6) OTP fallback "Oppure inserisci questo codice: 123 456 (15 min)"; (7) validità "15 minuti e una sola volta"; (8) footer GDPR Art.13/14 (titolare=committente, NIS2 Agile=responsabile Art.28, link informativa, "se non eri tu, ignora"). AC: OTP mai nei log; 1 sola CTA; nome committente in oggetto+corpo; scadenza solo se valorizzata. ## 4. Compilazione — ergonomia per tipo Chrome: progress bar (riuso `updateProgressBar`) + "{answered} di {total}"; badge scadenza sticky (neutral >7gg / warning ≤7gg / danger scaduto). Per tipo: - `yes_no_partial`: 3 bottoni grandi (radio nativi sotto) "Sì/Parzialmente/No". - `scale_1_5`: 5 bottoni con **etichette agli estremi sempre visibili** (da options min/max). Mai scala nuda. - `single_choice`: radio nativi verticali. `multi_choice`: checkbox + hint "Puoi selezionare più opzioni". - `text`: textarea + esempio placeholder. `number`: `inputmode=numeric`. - `file`: formati/dimensioni dichiarati prima del tap; `accept` + `capture` (foto documenti); feedback upload (%/check/rimuovi); errori formato/dimensione con suggerimento; async, sopravvive ad autosave (`file_ref`). - `help_text`: **sempre visibile** sotto la domanda (mai tooltip/hover — non funziona su touch). - **"Non applicabile" / "Non so"** dove sensato: conta come risposta per la progress bar, distinto dal "No" nello scoring. ## 5. Ricevuta, multi-referente, score - **Ricevuta (F22)**: email conferma al referente + **PDF risposte** scaricabile (da email e da §1.9). PDF = intestazione committente+fornitore, data, domande+risposte+allegati, riferimento normativo. Niente score se OFF. Snapshot immutabile → rigenerabile identico. - **Multi-referente/delega (F24)**: "Inoltra a un collega" → nuovo accesso OTP per quell'email, stessa campagna/`supplier_id`, autosave condiviso, traccia `answered_by`. Delegato vede solo quella campagna (no IDOR), stessi limiti OTP. - **Score (F23)**: `show_score_to_supplier` per template/campagna, **default false**. OFF = solo conferma invio. Toggle nell'editor "Mostra il punteggio al fornitore". ## 6. Editor lato azienda (F16) — no-code in supply-chain.html Tab "Categorie" / "Template questionari" / "Campagne-scadenze". Mappatura tecnico→no-code: - `slug`/`code`: auto-generati, nascosti. `order_index`: drag-and-drop (+ frecce ↑/↓ accessibili). - `options` JSON: editor a righe ("+ Aggiungi opzione"); scale = 2 campi "Etichetta minimo/massimo". - `reminder_offsets`: chip "14/7/1 giorni prima". `recurrence_months`: dropdown "Una tantum/Ogni 6/12/24 mesi/Personalizzato". - `weight`: dropdown "Bassa/Media/Alta". `type`: etichette umane. `required`: toggle. `nis2_ref`: chip informativo non editabile. `show_score_to_supplier`: toggle (default OFF). - **Anteprima "come lo vede il fornitore"**: stesso componente di rendering della compilazione (single source). - **Versioning (F17)**: salvare un template con campagne attive → modale "crea nuova versione v{x+1}, si applica solo alle campagne future; le {n} in corso restano su v{x}". Snapshot in `questionnaire_template_versions`. - **Cruscotto campagne**: semaforo (badge), colonne Fornitore/Questionario/Stato/Scadenza/Avanzamento/Azioni. Azioni rapide: Sollecita, Copia link (fallback manuale), Vedi risposte. **Bulk**: sollecita/avvia/esporta selezionati, con conferma e conteggio. Rispetta rate-limit reminder. ## 7. Accessibilità (F21) + mobile-first (F10) Elementi nativi (`button`, `input radio/checkbox` con `label`); OTP `inputmode=numeric autocomplete=one-time-code pattern=[0-9]* maxlength=6`; email `inputmode=email autocomplete=email`. ARIA: `aria-live=polite` per autosave/progress, `role=alert` per errori; focus order logico, focus torna sul campo dopo errore OTP; modali con focus trap + Esc + `aria-modal`. Drag-drop con alternativa tastiera. Contrasto ≥WCAG AA, stato non solo da colore. Target ≥44×44px, single-column, no hover-only. AC: flusso completo navigabile da sola tastiera e da screen reader; OTP autofill; nessun target <44px a 360px. ## 8. i18n (F20) Namespace `sp.*`. Lingua portale: (1) impostata dal cliente su campagna (`language it|en|auto`), (2) selezionabile dal fornitore (persistita in localStorage), (3) fallback `navigator.language` → IT. Email nella lingua della campagna. Chrome (header/footer/badge/errori/pulsanti) bilingue; i **contenuti domande** restano nella lingua in cui l'azienda li ha scritti (il selettore non auto-traduce i contenuti). AC: cambio lingua immediato senza reload; `campaign.language=en` → portale ed email in EN; nessuna stringa IT hardcoded in modalità EN.