Files
nis2-agile/public/supplier-portal.html
DevEnv nis2-agileandClaude Opus 4.8 05a1436d8c [I18N] Modulo Fornitori bilingue IT/EN (#8b) — v1.24.5
- supplier-portal.html (portale esterno OTP): i18n self-contained (dizionario inline + toggle IT/EN, ~79 stringhe), flusso OTP/token/ID/API invariato; domande questionario restano dati (non tradotte).
- supply-chain.html (interno): i18n estesa (5->25 data-i18n + 97 I18n.t).
- i18n.js: +82 chiavi sp.* (38->120). Cache-buster i18n.js su supply-chain.html. node --check OK, pagine 200, parita IT/EN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:32:18 +02:00

693 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">
<title>Portale Fornitori - NIS2 Agile</title>
<style>
:root{--primary:#06B6D4;--primary-dark:#0891b2;--gray-50:#f8fafc;--gray-100:#f1f5f9;--gray-200:#e2e8f0;--gray-400:#94a3b8;--gray-500:#64748b;--gray-700:#334155;--gray-900:#0f172a;--danger:#dc2626;--warning:#f59e0b;--success:#16a34a;--radius:10px}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--gray-100);color:var(--gray-900);line-height:1.6;min-height:100vh}
.wrap{max-width:560px;margin:0 auto;padding:24px 16px}
.card{background:#fff;border-radius:var(--radius);box-shadow:0 1px 3px rgba(0,0,0,.08);padding:24px;margin-bottom:16px}
.brand{text-align:center;margin-bottom:20px}
.brand h1{font-size:1.15rem;color:var(--primary-dark)}
.brand p{font-size:.85rem;color:var(--gray-500)}
h2{font-size:1.1rem;margin-bottom:12px}
p.sub{color:var(--gray-500);font-size:.9rem;margin-bottom:16px}
label{display:block;font-size:.85rem;font-weight:600;margin-bottom:6px;color:var(--gray-700)}
input[type=email],input[type=text],input[type=number],textarea,select{width:100%;padding:12px;border:1px solid var(--gray-200);border-radius:8px;font-size:1rem;font-family:inherit}
textarea{min-height:88px;resize:vertical}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:48px;padding:12px 20px;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;background:var(--primary);color:#fff;transition:background .15s}
.btn:hover{background:var(--primary-dark)}
.btn:disabled{opacity:.6;cursor:not-allowed}
.btn-ghost{background:transparent;color:var(--primary-dark);min-height:auto;width:auto;padding:6px 0;font-size:.85rem;text-decoration:underline}
.btn-secondary{background:var(--gray-100);color:var(--gray-700)}
.btn-secondary:hover{background:var(--gray-200)}
.msg{padding:12px 14px;border-radius:8px;font-size:.88rem;margin:12px 0}
.msg.err{background:#fef2f2;color:var(--danger);border-left:3px solid var(--danger)}
.msg.ok{background:#f0fdf4;color:var(--success);border-left:3px solid var(--success)}
.msg.info{background:#eff6ff;color:#1e40af;border-left:3px solid #1e40af}
.otp-input{letter-spacing:.4em;text-align:center;font-size:1.4rem;font-family:monospace}
.muted{color:var(--gray-400);font-size:.78rem;margin-top:8px}
.hidden{display:none!important}
.spinner{width:32px;height:32px;border:3px solid var(--gray-200);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite;margin:30px auto}
@keyframes spin{to{transform:rotate(360deg)}}
.q{padding:16px 0;border-bottom:1px solid var(--gray-100)}
.q:last-child{border-bottom:none}
.q-text{font-size:.95rem;font-weight:600;margin-bottom:4px}
.q-help{font-size:.8rem;color:var(--gray-500);margin-bottom:10px}
.q-ref{display:inline-block;background:#eff6ff;color:var(--primary-dark);font-size:.7rem;font-weight:700;padding:2px 7px;border-radius:20px;margin-left:6px}
.opts{display:flex;flex-wrap:wrap;gap:8px}
.opt{flex:0 0 auto}
.opt input{position:absolute;opacity:0}
.opt label{display:inline-flex;align-items:center;min-height:44px;padding:8px 16px;border:1.5px solid var(--gray-200);border-radius:24px;cursor:pointer;font-weight:600;margin:0;font-size:.9rem}
.opt input:checked+label{background:var(--primary);color:#fff;border-color:var(--primary)}
.scale-ends{display:flex;justify-content:space-between;font-size:.72rem;color:var(--gray-400);margin-top:4px}
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.72rem;font-weight:700}
.badge-warn{background:#fffbeb;color:#a16207}.badge-danger{background:#fef2f2;color:var(--danger)}.badge-neutral{background:var(--gray-100);color:var(--gray-500)}.badge-ok{background:#f0fdf4;color:var(--success)}
.due-bar{position:sticky;top:0;background:#fff;padding:10px 0;z-index:5;border-bottom:1px solid var(--gray-100);margin-bottom:8px}
.progress{height:6px;background:var(--gray-100);border-radius:3px;overflow:hidden;margin:8px 0}
.progress-fill{height:100%;background:var(--primary);transition:width .3s}
.camp{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid var(--gray-100);flex-wrap:wrap}
.camp:last-child{border-bottom:none}
.save-state{font-size:.78rem;color:var(--gray-400);text-align:right;min-height:18px}
.foot{text-align:center;font-size:.72rem;color:var(--gray-400);margin-top:20px;line-height:1.5}
.lang-toggle{display:flex;justify-content:flex-end;gap:0;margin-bottom:8px}
.lang-toggle button{background:transparent;border:1px solid var(--gray-200);color:var(--gray-500);font-size:.78rem;font-weight:700;padding:4px 10px;cursor:pointer;min-height:auto;line-height:1.2}
.lang-toggle button:first-child{border-radius:6px 0 0 6px;border-right:none}
.lang-toggle button:last-child{border-radius:0 6px 6px 0}
.lang-toggle button.active{background:var(--primary);color:#fff;border-color:var(--primary)}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="wrap">
<div class="lang-toggle">
<button type="button" id="lang-it" onclick="spSetLang('it')">IT</button>
<button type="button" id="lang-en" onclick="spSetLang('en')">EN</button>
</div>
<div class="brand">
<h1 id="brand-title" data-i18n="brand_title">Portale Fornitori</h1>
<p id="brand-sub" data-i18n="brand_sub">Compilazione questionari di sicurezza NIS2</p>
</div>
<!-- LOADING -->
<div id="view-loading" class="card"><div class="spinner"></div></div>
<!-- RICHIESTA ACCESSO -->
<div id="view-request" class="card hidden">
<h2 data-i18n="req_title">Accedi al portale</h2>
<p class="sub" data-i18n="req_sub">Inserisci l'email a cui hai ricevuto l'invito. Ti invieremo un link di accesso e un codice.</p>
<div id="req-msg"></div>
<label for="email" data-i18n="label_email">Email</label>
<input type="email" id="email" inputmode="email" autocomplete="email" placeholder="nome@azienda.it" data-i18n-ph="ph_email" autofocus>
<div style="height:12px"></div>
<button class="btn" id="btn-request" data-i18n="btn_request">Invia link di accesso</button>
<p class="muted"><span data-i18n="req_privacy_pre">Trattiamo la tua email solo per gestire il questionario.</span> <a href="#" id="privacy-link" data-i18n="req_privacy_link">Informativa privacy</a><span data-i18n="req_privacy_post">.</span></p>
</div>
<!-- EMAIL INVIATA / VERIFICA OTP -->
<div id="view-verify" class="card hidden">
<h2 data-i18n="verify_title">Controlla la tua email</h2>
<p class="sub"><span data-i18n="verify_sub_pre">Abbiamo inviato un link di accesso e un codice a</span> <strong id="verify-email"></strong><span data-i18n="verify_sub_post">. Apri l'email e tocca il pulsante, oppure inserisci qui sotto il codice.</span></p>
<div class="msg info"><span data-i18n="verify_spam_pre">Non la trovi? Controlla la cartella</span> <strong data-i18n="verify_spam_strong">spam/posta indesiderata</strong><span data-i18n="verify_spam_post">.</span></div>
<div id="verify-msg"></div>
<label for="otp" data-i18n="label_otp">Codice di accesso</label>
<input type="text" id="otp" class="otp-input" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9 ]*" maxlength="11" placeholder="········">
<div style="height:12px"></div>
<button class="btn" id="btn-verify" data-i18n="btn_verify">Entra nel portale</button>
<div style="height:10px"></div>
<button class="btn-ghost" id="btn-resend" disabled>Invia di nuovo l'email</button>
<button class="btn-ghost" id="btn-other-email" style="float:right" data-i18n="btn_other_email">Usa un'altra email</button>
</div>
<!-- DASHBOARD -->
<div id="view-dash" class="card hidden">
<h2 id="dash-supplier"></h2>
<p class="sub" id="dash-committente"></p>
<div id="dash-campaigns"></div>
<div style="height:14px"></div>
<button class="btn-ghost" id="btn-logout" data-i18n="btn_logout">Esci</button>
</div>
<!-- COMPILAZIONE -->
<div id="view-fill" class="card hidden">
<div class="due-bar">
<button class="btn-ghost" id="btn-back-dash" data-i18n="btn_back_dash">&larr; Torna ai questionari</button>
<div id="fill-due"></div>
<div class="progress"><div class="progress-fill" id="fill-progress" style="width:0"></div></div>
<div style="font-size:.78rem;color:var(--gray-500)" id="fill-count"></div>
<div class="save-state" id="fill-savestate"></div>
</div>
<h2 id="fill-title" data-i18n="fill_title_default">Questionario di sicurezza</h2>
<div id="fill-questions"></div>
<div id="fill-msg"></div>
<div style="height:8px"></div>
<button class="btn" id="btn-submit" data-i18n="btn_submit">Invia definitivo</button>
<div style="height:8px"></div>
<button class="btn btn-secondary" id="btn-draft" data-i18n="btn_draft">Salva bozza</button>
</div>
<!-- COMPLETATO -->
<div id="view-done" class="card hidden">
<div style="text-align:center;font-size:3rem">&#10003;</div>
<h2 style="text-align:center" data-i18n="done_title">Questionario inviato. Grazie!</h2>
<p class="sub" style="text-align:center" id="done-msg"></p>
<div id="done-score"></div>
<button class="btn-ghost" id="btn-done-back" style="display:block;margin:0 auto" data-i18n="btn_done_back">Torna ai questionari</button>
</div>
<div class="foot">
<span data-i18n="foot_l1_pre">Servizio fornito da</span> <strong>NIS2 Agile</strong> <span data-i18n="foot_l1_post">per conto del committente.</span><br>
<span data-i18n="foot_l2">Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).</span><br>
<span data-i18n="foot_l3">Se non eri tu a richiedere questo accesso, ignora l'email ricevuta.</span>
</div>
</div>
<script>
'use strict';
// ── i18n self-contained (IT/EN) — solo "chrome" UI; le domande del questionario sono DATI e NON vengono tradotte ──
const SPI18N = {
it: {
// brand
brand_title:'Portale Fornitori',
brand_sub:'Compilazione questionari di sicurezza NIS2',
// richiesta accesso
req_title:'Accedi al portale',
req_sub:"Inserisci l'email a cui hai ricevuto l'invito. Ti invieremo un link di accesso e un codice.",
label_email:'Email',
ph_email:'nome@azienda.it',
btn_request:'Invia link di accesso',
req_privacy_pre:'Trattiamo la tua email solo per gestire il questionario.',
req_privacy_link:'Informativa privacy',
req_privacy_post:'.',
privacy_alert:'Il committente è titolare del trattamento dei tuoi dati (nome, email, risposte) per la due-diligence di sicurezza NIS2 (Art. 6.1.f/c GDPR). NIS2 Agile agisce come responsabile (Art. 28). Hai diritto di accesso, rettifica e cancellazione. Per esercitarli contatta il committente.',
// verifica OTP
verify_title:'Controlla la tua email',
verify_sub_pre:'Abbiamo inviato un link di accesso e un codice a',
verify_sub_post:". Apri l'email e tocca il pulsante, oppure inserisci qui sotto il codice.",
verify_spam_pre:'Non la trovi? Controlla la cartella',
verify_spam_strong:'spam/posta indesiderata',
verify_spam_post:'.',
label_otp:'Codice di accesso',
btn_verify:'Entra nel portale',
btn_resend_email:"Invia di nuovo l'email",
resend_in:'Invia di nuovo tra {s}s',
btn_other_email:"Usa un'altra email",
new_email_sent:'Nuova email inviata.',
// errori richiesta/verifica
err_email_invalid:'Inserisci un indirizzo email valido.',
err_magic_invalid:'Link non valido o scaduto. Richiedi un nuovo accesso.',
err_otp_empty:'Inserisci il codice ricevuto via email.',
err_otp_invalid:'Codice non valido.',
// dashboard
btn_logout:'Esci',
dash_supplier_fallback:'Fornitore',
dash_committente:'Questionari richiesti da {name}',
dash_no_campaigns:'Nessun questionario in attesa. Se hai ricevuto una richiesta, contatta il tuo referente.',
camp_title:'Questionario sicurezza NIS2',
camp_due:'scade il {date}',
cta_receipt:'Vedi ricevuta',
cta_expired:'Scaduto',
cta_resume:'Riprendi',
cta_fill:'Compila',
badge_sent:'Inviato',
badge_expired:'Scaduto',
badge_draft:'In bozza',
badge_todo:'Da compilare',
// compilazione
btn_back_dash:'← Torna ai questionari',
btn_submit:'Invia definitivo',
btn_draft:'Salva bozza',
fill_title_default:'Questionario di sicurezza',
fill_title:'Questionario per {name}',
committente_fallback:'il committente',
due_expired_on:'Scaduto il {date}',
due_on:'Scade il {date}',
multi_hint:'Puoi selezionare più opzioni',
opt_yes:'Sì',
opt_partial:'Parzialmente',
opt_no:'No',
opt_na:'Non applicabile',
scale_min:'minimo',
scale_max:'massimo',
file_formats:'Formati: PDF, JPG, PNG.',
text_ph:'Scrivi qui...',
saving:'Salvataggio…',
draft_saved_at:'Bozza salvata {time}',
save_pending:'Salvataggio in sospeso',
progress_count:'{done} di {total} domande con risposta',
draft_saved:'Bozza salvata.',
err_save:'Errore nel salvataggio.',
err_missing_required:'Mancano {n} risposte obbligatorie.',
confirm_submit:"Confermi l'invio? Dopo l'invio non potrai modificare le risposte.",
err_missing_required_generic:'Mancano risposte obbligatorie.',
err_submit:"Errore nell'invio.",
expired_alert:'Il termine per la compilazione è scaduto.',
// completato
done_title:'Questionario inviato. Grazie!',
done_msg:'Abbiamo ricevuto le tue risposte.',
done_msg_sent:'Abbiamo ricevuto le tue risposte e le abbiamo inviate a {name}.',
done_score:'Punteggio: <strong>{score}/100</strong>',
btn_done_back:'Torna ai questionari',
// footer
foot_l1_pre:'Servizio fornito da',
foot_l1_post:'per conto del committente.',
foot_l2:'Il committente è titolare del trattamento; NIS2 Agile agisce come responsabile (Art. 28 GDPR).',
foot_l3:'Se non eri tu a richiedere questo accesso, ignora l\'email ricevuta.',
// generico
err_network:'Errore di connessione. Riprova.'
},
en: {
// brand
brand_title:'Supplier Portal',
brand_sub:'Complete your NIS2 security questionnaires',
// access request
req_title:'Sign in to the portal',
req_sub:"Enter the email address where you received the invitation. We'll send you an access link and a code.",
label_email:'Email',
ph_email:'name@company.com',
btn_request:'Send access link',
req_privacy_pre:'We process your email only to manage the questionnaire.',
req_privacy_link:'Privacy notice',
req_privacy_post:'.',
privacy_alert:'The client is the data controller of your data (name, email, answers) for the NIS2 security due diligence (Art. 6.1.f/c GDPR). NIS2 Agile acts as data processor (Art. 28). You have the right to access, rectification and erasure. To exercise them, contact the client.',
// OTP verification
verify_title:'Check your email',
verify_sub_pre:'We sent an access link and a code to',
verify_sub_post:'. Open the email and tap the button, or enter the code below.',
verify_spam_pre:"Can't find it? Check your",
verify_spam_strong:'spam/junk folder',
verify_spam_post:'.',
label_otp:'Access code',
btn_verify:'Enter the portal',
btn_resend_email:'Resend the email',
resend_in:'Resend in {s}s',
btn_other_email:'Use a different email',
new_email_sent:'New email sent.',
// request/verify errors
err_email_invalid:'Please enter a valid email address.',
err_magic_invalid:'Invalid or expired link. Please request access again.',
err_otp_empty:'Enter the code you received by email.',
err_otp_invalid:'Invalid code.',
// dashboard
btn_logout:'Log out',
dash_supplier_fallback:'Supplier',
dash_committente:'Questionnaires requested by {name}',
dash_no_campaigns:'No questionnaires pending. If you received a request, please contact your reference person.',
camp_title:'NIS2 security questionnaire',
camp_due:'due {date}',
cta_receipt:'View receipt',
cta_expired:'Expired',
cta_resume:'Resume',
cta_fill:'Fill in',
badge_sent:'Submitted',
badge_expired:'Expired',
badge_draft:'Draft',
badge_todo:'To complete',
// filling
btn_back_dash:'← Back to questionnaires',
btn_submit:'Submit final',
btn_draft:'Save draft',
fill_title_default:'Security questionnaire',
fill_title:'Questionnaire for {name}',
committente_fallback:'the client',
due_expired_on:'Expired on {date}',
due_on:'Due {date}',
multi_hint:'You can select multiple options',
opt_yes:'Yes',
opt_partial:'Partially',
opt_no:'No',
opt_na:'Not applicable',
scale_min:'minimum',
scale_max:'maximum',
file_formats:'Formats: PDF, JPG, PNG.',
text_ph:'Type here...',
saving:'Saving…',
draft_saved_at:'Draft saved {time}',
save_pending:'Save pending',
progress_count:'{done} of {total} questions answered',
draft_saved:'Draft saved.',
err_save:'Error while saving.',
err_missing_required:'{n} required answers are missing.',
confirm_submit:'Confirm submission? After submitting you will not be able to change your answers.',
err_missing_required_generic:'Required answers are missing.',
err_submit:'Error during submission.',
expired_alert:'The deadline for completion has passed.',
// completed
done_title:'Questionnaire submitted. Thank you!',
done_msg:'We have received your answers.',
done_msg_sent:'We have received your answers and sent them to {name}.',
done_score:'Score: <strong>{score}/100</strong>',
btn_done_back:'Back to questionnaires',
// footer
foot_l1_pre:'Service provided by',
foot_l1_post:'on behalf of the client.',
foot_l2:'The client is the data controller; NIS2 Agile acts as data processor (Art. 28 GDPR).',
foot_l3:'If you did not request this access, please ignore the email you received.',
// generic
err_network:'Connection error. Please try again.'
}
};
let SP_LANG = sessionStorage.getItem('sp_lang') || (navigator.language||'it').slice(0,2);
if(SP_LANG!=='en') SP_LANG='it';
// spt(key, params) — ritorna la stringa tradotta con sostituzioni semplici {placeholder}
function spt(k,params){
let s = (SPI18N[SP_LANG]&&SPI18N[SP_LANG][k]) || SPI18N.it[k] || k;
if(params) for(const p in params){ s = s.replace(new RegExp('\\{'+p+'\\}','g'), params[p]); }
return s;
}
// applica le traduzioni al testo statico marcato con data-i18n / data-i18n-ph
function spApplyI18n(){
document.querySelectorAll('[data-i18n]').forEach(e=>{ e.textContent = spt(e.getAttribute('data-i18n')); });
document.querySelectorAll('[data-i18n-ph]').forEach(e=>{ e.setAttribute('placeholder', spt(e.getAttribute('data-i18n-ph'))); });
const it=document.getElementById('lang-it'), en=document.getElementById('lang-en');
if(it) it.classList.toggle('active', SP_LANG==='it');
if(en) en.classList.toggle('active', SP_LANG==='en');
document.documentElement.setAttribute('lang', SP_LANG);
}
// toggle lingua: imposta SP_LANG, persiste, riapplica statico + ri-renderizza il dinamico già presente
function spSetLang(lang){
SP_LANG = (lang==='en') ? 'en' : 'it';
sessionStorage.setItem('sp_lang', SP_LANG);
spApplyI18n();
// ri-render delle parti dinamiche eventualmente visibili (le funzioni di render NON sono modificate)
if(typeof SP_LAST_DASH==='object' && SP_LAST_DASH && !document.getElementById('view-dash').classList.contains('hidden')){ renderDash(SP_LAST_DASH); }
if(CURRENT && !document.getElementById('view-fill').classList.contains('hidden')){
if($('fill-title') && SP_LAST_FILL_COMM!=null){ $('fill-title').textContent = SP_LAST_FILL_COMM ? spt('fill_title',{name:SP_LAST_FILL_COMM}) : spt('fill_title',{name:spt('committente_fallback')}); }
renderDueBar(); renderQuestions();
}
if(!document.getElementById('view-done').classList.contains('hidden') && SP_LAST_DONE){ renderDone(SP_LAST_DONE.d, SP_LAST_DONE.result); }
// se il timer "resend" è attivo lo lasciamo aggiornare da solo; se è già scaduto ne aggiorniamo l'etichetta
const rb=$('btn-resend'); if(rb && !rb.disabled){ rb.textContent = spt('btn_resend_email'); }
}
let SP_LAST_DASH = null; // memorizza l'ultimo data della dashboard per il re-render al cambio lingua
let SP_LAST_FILL_COMM = null; // nome committente per il titolo del fill
let SP_LAST_DONE = null; // {d, result} per il re-render della schermata "completato"
const API = '/api/supplier-portal';
let TOKEN = sessionStorage.getItem('sp_token') || '';
let RESEND_TIMER = null;
let CURRENT = null; // {campaignId, questions, answers, dueAt}
let SAVE_DEBOUNCE = null;
function $(id){return document.getElementById(id);}
function show(view){['loading','request','verify','dash','fill','done'].forEach(v=>$('view-'+v).classList.toggle('hidden',v!==view));}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function msg(el,text,type){el.innerHTML=text?('<div class="msg '+type+'">'+esc(text)+'</div>'):'';}
async function api(method,path,body,auth){
const h={'Content-Type':'application/json'};
if(auth&&TOKEN)h['Authorization']='Bearer '+TOKEN;
const opt={method,headers:h};
if(body)opt.body=JSON.stringify(body);
let res,json;
try{res=await fetch(API+path,opt);json=await res.json();}
catch(e){return{success:false,message:spt('err_network'),_net:true};}
json._status=res.status;
return json;
}
// ── Boot: magic-link? token? ──
(async function boot(){
spApplyI18n();
const params=new URLSearchParams(location.search);
const magic=params.get('magic');
if(magic){
show('loading');
const r=await api('POST','/verify-otp',{magic_token:magic},false);
history.replaceState({},'',location.pathname); // pulisci URL
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
show('request');
msg($('req-msg'),(r.message||spt('err_magic_invalid')),'err');
return;
}
if(TOKEN){
show('loading');
const r=await api('GET','/me',null,true);
if(r.success&&r.data){renderDash(r.data);return;}
TOKEN='';sessionStorage.removeItem('sp_token');
}
show('request');
})();
// ── Richiesta OTP ──
$('btn-request').onclick=async function(){
const email=$('email').value.trim();
if(!email||email.indexOf('@')<0){msg($('req-msg'),spt('err_email_invalid'),'err');return;}
this.disabled=true;
const r=await api('POST','/request-otp',{email},false);
this.disabled=false;
// risposta sempre opaca: passiamo comunque alla verifica
$('verify-email').textContent=email;
sessionStorage.setItem('sp_email',email);
show('verify');
startResendCountdown();
$('otp').focus();
};
$('email').addEventListener('keydown',e=>{if(e.key==='Enter')$('btn-request').click();});
function startResendCountdown(){
let s=60;const btn=$('btn-resend');btn.disabled=true;
if(RESEND_TIMER)clearInterval(RESEND_TIMER);
const upd=()=>{btn.textContent=s>0?spt('resend_in',{s:s}):spt('btn_resend_email');if(s<=0){btn.disabled=false;clearInterval(RESEND_TIMER);}s--;};
upd();RESEND_TIMER=setInterval(upd,1000);
}
$('btn-resend').onclick=async function(){
const email=sessionStorage.getItem('sp_email')||'';
if(!email)return;
await api('POST','/request-otp',{email},false);
startResendCountdown();
msg($('verify-msg'),spt('new_email_sent'),'ok');
};
$('btn-other-email').onclick=()=>{show('request');$('email').focus();};
// ── Verifica OTP ──
$('btn-verify').onclick=async function(){
const email=sessionStorage.getItem('sp_email')||'';
const code=($('otp').value||'').replace(/\D/g,'');
if(!code){msg($('verify-msg'),spt('err_otp_empty'),'err');return;}
this.disabled=true;
const r=await api('POST','/verify-otp',{email,code},false);
this.disabled=false;
if(r.success&&r.data&&r.data.token){onLogin(r.data);return;}
// errori specifici con via d'uscita
let m=r.message||spt('err_otp_invalid');
msg($('verify-msg'),m,'err');
$('otp').value='';$('otp').focus();
};
$('otp').addEventListener('keydown',e=>{if(e.key==='Enter')$('btn-verify').click();});
function onLogin(data){
TOKEN=data.token;sessionStorage.setItem('sp_token',TOKEN);
// carica /me per la dashboard completa
api('GET','/me',null,true).then(r=>{if(r.success&&r.data)renderDash(r.data);else{show('request');}});
}
$('btn-logout').onclick=()=>{TOKEN='';sessionStorage.removeItem('sp_token');sessionStorage.removeItem('sp_email');show('request');};
// ── Dashboard ──
function renderDash(data){
SP_LAST_DASH=data; // per il re-render al cambio lingua
const sup=data.supplier||{};const comm=data.committente||{};
$('dash-supplier').textContent=sup.name||spt('dash_supplier_fallback');
$('dash-committente').textContent=comm.name?spt('dash_committente',{name:comm.name}):'';
$('brand-title').textContent=comm.name||spt('brand_title');
const camps=(data.campaigns||[]);
const open=camps.filter(c=>c.status==='sent'||c.status==='in_progress');
// caso 1 solo questionario aperto → vai diretto
if(open.length===1&&camps.length===1){openCampaign(open[0].id);return;}
let html='';
if(camps.length===0){html='<div class="msg info">'+esc(spt('dash_no_campaigns'))+'</div>';}
camps.forEach(c=>{
const b=campBadge(c);
const cta=(c.status==='completed')?spt('cta_receipt'):(c.status==='expired'?spt('cta_expired'):(c.status==='in_progress'?spt('cta_resume'):spt('cta_fill')));
const dis=(c.status==='expired')?'disabled':'';
html+='<div class="camp"><div><div style="font-weight:600">'+esc(spt('camp_title'))+'</div><div style="font-size:.8rem;color:var(--gray-500)">'+b+(c.due_at?(' · '+esc(spt('camp_due',{date:fmtDate(c.due_at)}))):'')+'</div></div>'+
'<button class="btn" style="width:auto;min-height:40px;padding:8px 16px" onclick="openCampaign('+c.id+')" '+dis+'>'+esc(cta)+'</button></div>';
});
$('dash-campaigns').innerHTML=html;
show('dash');
}
function campBadge(c){
if(c.status==='completed')return '<span class="badge badge-ok">'+esc(spt('badge_sent'))+'</span>';
if(c.status==='expired')return '<span class="badge badge-danger">'+esc(spt('badge_expired'))+'</span>';
if(c.status==='in_progress')return '<span class="badge badge-warn">'+esc(spt('badge_draft'))+'</span>';
return '<span class="badge badge-neutral">'+esc(spt('badge_todo'))+'</span>';
}
function fmtDate(d){try{return new Date(d).toLocaleDateString(SP_LANG==='en'?'en-GB':'it-IT');}catch(e){return d;}}
// ── Compilazione ──
window.openCampaign=async function(id){
show('loading');
const r=await api('GET','/'+id,null,true);
if(!r.success||!r.data){
if(r._status===410){alert(spt('expired_alert'));}
const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');return;
}
const d=r.data;
if(d.campaign.status==='completed'){renderDone(d,null);return;}
CURRENT={campaignId:id,questions:d.questions||[],answers:d.answers||{},dueAt:d.campaign.due_at,showScore:d.campaign.show_score};
SP_LAST_FILL_COMM=(d.committente&&d.committente.name)||''; // per il re-render del titolo al cambio lingua
$('fill-title').textContent=SP_LAST_FILL_COMM?spt('fill_title',{name:SP_LAST_FILL_COMM}):spt('fill_title',{name:spt('committente_fallback')});
renderDueBar();
renderQuestions();
msg($('fill-msg'),'','');
show('fill');
};
function renderDueBar(){
const due=CURRENT.dueAt;let html='';
if(due){const days=Math.ceil((new Date(due)-new Date())/86400000);
const cls=days<0?'badge-danger':(days<=7?'badge-warn':'badge-neutral');
html='<span class="badge '+cls+'">'+esc(days<0?spt('due_expired_on',{date:fmtDate(due)}):spt('due_on',{date:fmtDate(due)}))+'</span>';}
$('fill-due').innerHTML=html;
}
function answerKey(q){return q.id!=null?String(q.id):('code:'+q.code);}
function getAns(q){return CURRENT.answers[answerKey(q)]||{};}
function renderQuestions(){
let html='';
CURRENT.questions.forEach((q,i)=>{
const a=getAns(q);const val=a.value;
html+='<div class="q" data-qi="'+i+'"><div class="q-text">'+esc(q.text)+(q.nis2_ref?'<span class="q-ref">'+esc(q.nis2_ref)+'</span>':'')+(q.is_required?' <span style="color:var(--danger)">*</span>':'')+'</div>';
if(q.help_text)html+='<div class="q-help">'+esc(q.help_text)+'</div>';
html+=renderInput(q,i,val,a.text);
html+='</div>';
});
$('fill-questions').innerHTML=html;
bindInputs();
updateProgress();
}
function renderInput(q,i,val,text){
const name='q'+i;
if(q.type==='yes_no_partial'){
return optGroup(name,[['yes',spt('opt_yes')],['partial',spt('opt_partial')],['no',spt('opt_no')],['not_applicable',spt('opt_na')]],val,'radio');
}
if(q.type==='single_choice'){
return optGroup(name,(q.options||[]).map(o=>[o,o]),val,'radio');
}
if(q.type==='multi_choice'){
const vals=Array.isArray(val)?val:[];
return '<div style="font-size:.75rem;color:var(--gray-400);margin-bottom:6px">'+esc(spt('multi_hint'))+'</div>'+optGroup(name,(q.options||[]).map(o=>[o,o]),vals,'checkbox');
}
if(q.type==='scale_1_5'){
const ends=q.options||{};
let h=optGroup(name,[['1','1'],['2','2'],['3','3'],['4','4'],['5','5']],val,'radio');
h+='<div class="scale-ends"><span>1 = '+esc(ends.min||ends['1']||spt('scale_min'))+'</span><span>5 = '+esc(ends.max||ends['5']||spt('scale_max'))+'</span></div>';
return h;
}
if(q.type==='number'){
return '<input type="number" inputmode="numeric" name="'+name+'" value="'+(val!=null?esc(val):'')+'" data-qi="'+i+'">';
}
if(q.type==='file'){
return '<div class="muted">'+esc(spt('file_formats'))+' <input type="file" name="'+name+'" accept=".pdf,.jpg,.jpeg,.png" data-qi="'+i+'"><div data-fileinfo="'+i+'">'+(text?esc(text):'')+'</div></div>';
}
// text
return '<textarea name="'+name+'" data-qi="'+i+'" placeholder="'+esc(spt('text_ph'))+'">'+(val!=null?esc(val):'')+'</textarea>';
}
function optGroup(name,opts,val,kind){
const arr=Array.isArray(val)?val.map(String):[String(val)];
let h='<div class="opts">';
opts.forEach((o,j)=>{
const id=name+'_'+j;const checked=arr.indexOf(String(o[0]))>=0?'checked':'';
h+='<div class="opt"><input type="'+kind+'" id="'+id+'" name="'+name+'" value="'+esc(o[0])+'" '+checked+'><label for="'+id+'">'+esc(o[1])+'</label></div>';
});
return h+'</div>';
}
function bindInputs(){
$('fill-questions').querySelectorAll('input,textarea,select').forEach(el=>{
const ev=(el.type==='radio'||el.type==='checkbox')?'change':'input';
el.addEventListener(ev,()=>{collectAndAutosave();});
});
}
function collect(){
const out=[];
CURRENT.questions.forEach((q,i)=>{
const name='q'+i;let value=null,text=null;
if(q.type==='multi_choice'){
value=Array.from($('fill-questions').querySelectorAll('input[name="'+name+'"]:checked')).map(e=>e.value);
if(value.length===0)value=null;
}else if(q.type==='yes_no_partial'||q.type==='single_choice'||q.type==='scale_1_5'){
const sel=$('fill-questions').querySelector('input[name="'+name+'"]:checked');
value=sel?sel.value:null;
}else if(q.type==='number'){
const el=$('fill-questions').querySelector('[name="'+name+'"]');value=el&&el.value!==''?el.value:null;
}else if(q.type==='file'){
const el=$('fill-questions').querySelector('[name="'+name+'"]');text=el&&el.files&&el.files[0]?el.files[0].name:null;
}else{
const el=$('fill-questions').querySelector('[name="'+name+'"]');text=el&&el.value!==''?el.value:null;
}
const entry={};if(q.id!=null)entry.question_id=q.id;else entry.question_code=q.code;
if(value!=null)entry.value=value;if(text!=null)entry.text=text;
// aggiorna cache locale
CURRENT.answers[answerKey(q)]={value:value,text:text};
if(value!=null||text!=null)out.push(entry);
});
return out;
}
function collectAndAutosave(){
updateProgress();
$('fill-savestate').textContent=spt('saving');
if(SAVE_DEBOUNCE)clearTimeout(SAVE_DEBOUNCE);
SAVE_DEBOUNCE=setTimeout(async()=>{
const answers=collect();
if(answers.length===0){$('fill-savestate').textContent='';return;}
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true);
$('fill-savestate').textContent=r.success?spt('draft_saved_at',{time:new Date().toLocaleTimeString(SP_LANG==='en'?'en-GB':'it-IT').slice(0,5)}):spt('save_pending');
},800);
}
function updateProgress(){
const total=CURRENT.questions.length;let done=0;
CURRENT.questions.forEach(q=>{const a=getAns(q);if((a.value!=null&&a.value!=='')||(a.text!=null&&a.text!==''))done++;});
$('fill-progress').style.width=(total?Math.round(done/total*100):0)+'%';
$('fill-count').textContent=spt('progress_count',{done:done,total:total});
}
$('btn-draft').onclick=async function(){
const answers=collect();
const r=await api('PATCH','/'+CURRENT.campaignId+'/answers',{answers},true);
msg($('fill-msg'),r.success?spt('draft_saved'):spt('err_save'),r.success?'ok':'err');
};
$('btn-submit').onclick=async function(){
const answers=collect();
// verifica obbligatorie lato client
const missing=[];
CURRENT.questions.forEach(q=>{if(q.is_required){const a=getAns(q);if(!((a.value!=null&&a.value!=='')||(a.text!=null&&a.text!=='')))missing.push(q);}});
if(missing.length){msg($('fill-msg'),spt('err_missing_required',{n:missing.length}),'err');return;}
if(!confirm(spt('confirm_submit')))return;
this.disabled=true;
const r=await api('POST','/'+CURRENT.campaignId+'/submit',{answers},true);
this.disabled=false;
if(r.success){renderDone({committente:{name:$('brand-title').textContent}},r.data);return;}
if(r.data&&r.data.missing){msg($('fill-msg'),spt('err_missing_required_generic'),'err');return;}
msg($('fill-msg'),r.message||spt('err_submit'),'err');
};
$('btn-back-dash').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);};
function renderDone(d,result){
SP_LAST_DONE={d:d,result:result}; // per il re-render al cambio lingua
const comm=(d&&d.committente&&d.committente.name)||'';
$('done-msg').textContent=comm?spt('done_msg_sent',{name:comm}):spt('done_msg');
let s='';
if(result&&result.show_score&&result.score!=null){
s='<div class="msg info" style="text-align:center">'+spt('done_score',{score:result.score})+'</div>';
}
$('done-score').innerHTML=s;
show('done');
}
$('btn-done-back').onclick=async function(){const me=await api('GET','/me',null,true);if(me.success)renderDash(me.data);else show('request');};
$('privacy-link').onclick=function(e){e.preventDefault();alert(spt('privacy_alert'));};
</script>
</body>
</html>