[FEAT] UI: guida online, landing EN, mobile-conversion, ai-assistant, bug-reporter + help/i18n
- public/guida.html, index-en.html, service-continuity.html - public/js/ai-assistant.js, bug-reporter.js (FAB supporto) - public/mobile-conversion.css/js - index.html, common.js, help.js, risks.html: aggiornamenti UI/help Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c0bf7b6c15
commit
1d934e4e63
775
public/guida.html
Normal file
775
public/guida.html
Normal file
@ -0,0 +1,775 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Guida all'uso - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
/* Stili specifici per la guida — non interferiscono col resto */
|
||||
.guide-wrap { max-width: 920px; margin: 0 auto; }
|
||||
.guide-toc {
|
||||
position: sticky; top: 16px;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 10px; padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.guide-toc h4 { margin: 0 0 10px; font-size: 0.9rem; color: var(--text-muted, #6b7280); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.guide-toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: 24px; }
|
||||
.guide-toc li { padding: 4px 0; }
|
||||
.guide-toc a { color: var(--primary, #1e40af); text-decoration: none; font-size: 0.93rem; }
|
||||
.guide-toc a:hover { text-decoration: underline; }
|
||||
|
||||
.guide-section { margin: 40px 0; padding-top: 16px; scroll-margin-top: 16px; }
|
||||
.guide-section h2 {
|
||||
color: var(--primary, #1e40af);
|
||||
border-bottom: 2px solid var(--primary, #1e40af);
|
||||
padding-bottom: 8px; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.guide-section h2 .num {
|
||||
background: var(--primary, #1e40af); color: #fff;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 0.9rem; font-weight: 700;
|
||||
}
|
||||
.guide-section h3 { color: #374151; margin-top: 24px; }
|
||||
|
||||
.plain-box {
|
||||
background: #eff6ff; border-left: 4px solid #1e40af;
|
||||
padding: 14px 18px; border-radius: 6px; margin: 14px 0;
|
||||
}
|
||||
.plain-box strong { color: #1e40af; }
|
||||
|
||||
.example-box {
|
||||
background: #fef3c7; border-left: 4px solid #f59e0b;
|
||||
padding: 14px 18px; border-radius: 6px; margin: 14px 0;
|
||||
}
|
||||
.example-box strong { color: #92400e; }
|
||||
|
||||
.norm-box {
|
||||
background: #f3f4f6; border-left: 4px solid #6b7280;
|
||||
padding: 14px 18px; border-radius: 6px; margin: 14px 0;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.norm-box .article-tag {
|
||||
display: inline-block; background: #1e40af; color: #fff;
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 0.78rem;
|
||||
font-weight: 600; margin-right: 8px;
|
||||
}
|
||||
|
||||
.step-list { counter-reset: step; list-style: none; padding-left: 0; }
|
||||
.step-list li {
|
||||
counter-increment: step; padding: 12px 12px 12px 56px;
|
||||
position: relative; margin: 8px 0;
|
||||
background: #f9fafb; border-radius: 6px;
|
||||
}
|
||||
.step-list li::before {
|
||||
content: counter(step);
|
||||
position: absolute; left: 14px; top: 12px;
|
||||
background: var(--primary, #1e40af); color: #fff;
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glossario dt {
|
||||
font-weight: 700; color: var(--primary, #1e40af);
|
||||
margin-top: 14px; font-size: 1.02rem;
|
||||
}
|
||||
.glossario dd { margin-left: 0; margin-bottom: 6px; color: #374151; }
|
||||
.glossario .acro { font-family: 'Courier New', monospace; background: #e0e7ff; padding: 2px 6px; border-radius: 3px; }
|
||||
|
||||
.pillar-card {
|
||||
border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
padding: 16px; margin: 10px 0;
|
||||
background: #fff;
|
||||
}
|
||||
.pillar-card h4 {
|
||||
margin: 0 0 8px; color: var(--primary, #1e40af);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.pillar-card .pillar-num {
|
||||
background: var(--primary, #1e40af); color: #fff;
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.guide-toc ul { columns: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h2>Guida all'uso di NIS2 Agile</h2>
|
||||
<div class="content-header-actions">
|
||||
<span class="text-muted">Per chi inizia da zero</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="guide-wrap">
|
||||
|
||||
<!-- Intro -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p style="font-size:1.05rem; line-height:1.7; margin:0;">
|
||||
Benvenuto. Questa guida ti accompagna passo passo nell'uso della piattaforma <strong>NIS2 Agile</strong>,
|
||||
spiegando con parole semplici cosa devi fare e perché — senza dare per scontato che tu sia un esperto
|
||||
di cybersecurity. Troverai per ogni argomento <strong style="color:#1e40af;">la spiegazione in parole semplici</strong>,
|
||||
un <strong style="color:#92400e;">esempio pratico</strong> e <strong style="color:#6b7280;">cosa dice la norma</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOC -->
|
||||
<nav class="guide-toc" aria-label="Indice">
|
||||
<h4>Indice</h4>
|
||||
<ul>
|
||||
<li><a href="#cap-1">1. Cos'è la NIS2 (5 minuti)</a></li>
|
||||
<li><a href="#cap-2">2. La tua azienda è "in scope"?</a></li>
|
||||
<li><a href="#cap-3">3. Cosa fa la piattaforma</a></li>
|
||||
<li><a href="#cap-4">4. Il percorso tipico</a></li>
|
||||
<li><a href="#cap-5">5. Gap Analysis (Art. 21)</a></li>
|
||||
<li><a href="#cap-6">6. Gestione dei Rischi</a></li>
|
||||
<li><a href="#cap-7">7. Incidenti (Art. 23)</a></li>
|
||||
<li><a href="#cap-8">8. Policy e procedure</a></li>
|
||||
<li><a href="#cap-9">9. Fornitori (Supply Chain)</a></li>
|
||||
<li><a href="#cap-10">10. Formazione (Art. 20)</a></li>
|
||||
<li><a href="#cap-11">11. Asset</a></li>
|
||||
<li><a href="#cap-12">12. Audit & Report</a></li>
|
||||
<li><a href="#cap-13">13. Segnalazioni interne</a></li>
|
||||
<li><a href="#cap-14">14. AI: come usarla</a></li>
|
||||
<li><a href="#cap-15">15. Glossario</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Cap 1 -->
|
||||
<section id="cap-1" class="guide-section">
|
||||
<h2><span class="num">1</span> Cos'è la NIS2 (in 5 minuti)</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> NIS2 è una legge europea (Direttiva UE 2022/2555, recepita in Italia
|
||||
con il D.Lgs. 138/2024) che obbliga molte aziende a proteggere bene i propri sistemi informatici.
|
||||
Lo scopo è evitare che attacchi hacker, fughe di dati o blocchi dei sistemi danneggino cittadini,
|
||||
servizi essenziali (ospedali, energia, acqua, trasporti) e l'economia europea.
|
||||
</div>
|
||||
|
||||
<p>NIS2 stabilisce quattro grandi obblighi:</p>
|
||||
<ol>
|
||||
<li><strong>Misurare il rischio cyber</strong> della propria azienda e migliorare le difese (Art. 21).</li>
|
||||
<li><strong>Segnalare gli incidenti</strong> gravi alle autorità entro tempi precisi (Art. 23).</li>
|
||||
<li><strong>Formare il personale</strong>, soprattutto i dirigenti (Art. 20).</li>
|
||||
<li><strong>Controllare i fornitori</strong> che hanno accesso ai propri sistemi (Art. 21, lettera d).</li>
|
||||
</ol>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio reale.</strong> Una clinica privata con 250 dipendenti è "in scope" NIS2 perché
|
||||
opera nel settore sanitario (settore essenziale). Se subisce un ransomware che blocca le cartelle cliniche
|
||||
per 8 ore, deve notificarlo al CSIRT Italia entro 24 ore con una prima segnalazione e poi entro 72 ore
|
||||
con i dettagli completi.
|
||||
</div>
|
||||
|
||||
<div class="norm-box">
|
||||
<span class="article-tag">Considerando UE</span>
|
||||
La direttiva sostituisce la "NIS1" del 2016 ampliando settori coinvolti, inasprendo le sanzioni
|
||||
(fino al 2% del fatturato globale o 10 milioni €) e introducendo la responsabilità diretta degli
|
||||
organi di vertice (amministratori) sulla cybersecurity.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 2 -->
|
||||
<section id="cap-2" class="guide-section">
|
||||
<h2><span class="num">2</span> La tua azienda è "in scope"?</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> "In scope" vuol dire che la legge si applica a te. Dipende da
|
||||
<em>cosa fai</em> (settore) e <em>quanto sei grande</em> (dipendenti e fatturato).
|
||||
</div>
|
||||
|
||||
<h3>I settori coinvolti</h3>
|
||||
<p>Sono divisi in due gruppi:</p>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4>Settori <strong style="color:#dc2626;">essenziali</strong> (controlli più stringenti)</h4>
|
||||
<p>Energia, trasporti, banche, finanza, sanità, acqua potabile e reflue, infrastrutture digitali
|
||||
(data center, DNS, TLD), Pubblica Amministrazione, spazio.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4>Settori <strong style="color:#f59e0b;">importanti</strong> (controlli normali)</h4>
|
||||
<p>Servizi postali, gestione rifiuti, chimica, alimentare, manifattura (apparecchi medici, computer,
|
||||
veicoli, ecc.), provider digitali (motori di ricerca, social, marketplace), ricerca.</p>
|
||||
</div>
|
||||
|
||||
<h3>Le soglie dimensionali</h3>
|
||||
<ul>
|
||||
<li><strong>Media impresa</strong> = 50–249 dipendenti <em>oppure</em> fatturato tra 10 e 50 milioni €.</li>
|
||||
<li><strong>Grande impresa</strong> = ≥250 dipendenti <em>oppure</em> fatturato >50 milioni €.</li>
|
||||
</ul>
|
||||
<p>In generale: medie e grandi imprese nei settori sopra elencati sono in scope. Le piccole sono in scope
|
||||
solo in casi particolari (es. fornitori di servizi DNS, TLD, registrar).</p>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio pratico.</strong> "Aurora Sanità S.p.A." con 480 dipendenti e 92 milioni € di fatturato,
|
||||
settore sanitario → <strong>essenziale + grande</strong> → pienamente in scope. Deve registrarsi sul portale
|
||||
ACN (Agenzia per la Cybersicurezza Nazionale) entro le scadenze.
|
||||
</div>
|
||||
|
||||
<div class="norm-box">
|
||||
<span class="article-tag">Art. 2 + Allegati I e II</span>
|
||||
L'ambito di applicazione è definito dagli articoli 2 della Direttiva e specificato negli Allegati I (settori
|
||||
essenziali) e II (settori importanti) del D.Lgs. 138/2024.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 3 -->
|
||||
<section id="cap-3" class="guide-section">
|
||||
<h2><span class="num">3</span> Cosa fa la piattaforma NIS2 Agile</h2>
|
||||
|
||||
<p>La piattaforma ti aiuta a fare <strong>tutto quello che la NIS2 chiede</strong>, in modo organizzato e
|
||||
documentabile. Non sostituisce il giudizio di un consulente o un CISO, ma ti dà gli strumenti per:</p>
|
||||
|
||||
<ul>
|
||||
<li>Capire <strong>quanto sei conforme</strong> oggi (Gap Analysis con 80 domande).</li>
|
||||
<li>Tenere un <strong>registro dei rischi</strong> aggiornato.</li>
|
||||
<li>Gestire gli <strong>incidenti</strong> con i moduli di notifica già pronti per il CSIRT.</li>
|
||||
<li>Generare <strong>policy</strong> di sicurezza usando l'AI.</li>
|
||||
<li>Monitorare i <strong>fornitori critici</strong>.</li>
|
||||
<li>Pianificare la <strong>formazione</strong> dei dipendenti.</li>
|
||||
<li>Inventariare gli <strong>asset</strong> critici.</li>
|
||||
<li>Estrarre <strong>report</strong> per audit interni o per le autorità.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Cap 4 -->
|
||||
<section id="cap-4" class="guide-section">
|
||||
<h2><span class="num">4</span> Il percorso tipico (cosa fare per primo)</h2>
|
||||
|
||||
<p>Se è la tua prima volta, segui questi passi in ordine:</p>
|
||||
|
||||
<ol class="step-list">
|
||||
<li><strong>Completa l'Onboarding</strong> — inserisci i dati aziendali (puoi caricare la visura
|
||||
e l'AI estrae i dati automaticamente). Classifica la tua azienda come essenziale/importante.</li>
|
||||
<li><strong>Fai un primo Assessment (Gap Analysis)</strong> — rispondi alle 80 domande, anche se
|
||||
in più sessioni. Otterrai un punteggio di maturità complessiva.</li>
|
||||
<li><strong>Crea il Risk Register</strong> — parti dai rischi più ovvi (ransomware, phishing,
|
||||
guasto sistemi). L'AI può suggerirti rischi tipici del tuo settore.</li>
|
||||
<li><strong>Inserisci gli asset critici</strong> — i sistemi/dati senza i quali l'azienda si ferma.</li>
|
||||
<li><strong>Mappa i fornitori critici</strong> — quelli con accesso ai tuoi sistemi o dati.</li>
|
||||
<li><strong>Genera/approva le policy fondamentali</strong> — usa l'AI per le bozze, poi rivedile.</li>
|
||||
<li><strong>Pianifica la formazione</strong> — soprattutto per i dirigenti (obbligo Art. 20).</li>
|
||||
<li><strong>Quando arriva un incidente</strong> — usalo dal modulo Incidenti per gestire la notifica
|
||||
24h/72h/30d.</li>
|
||||
<li><strong>Genera il report esecutivo</strong> dalla sezione Report — utile per il board.</li>
|
||||
</ol>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Tempi indicativi.</strong> Un primo ciclo "decente" si fa in 6–8 settimane di lavoro
|
||||
spalmate, con 2 persone (un IT e un compliance). Non puntare alla perfezione subito: meglio
|
||||
coprire <em>tutte</em> le 10 categorie al 50% che 3 al 100% e 7 a zero.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 5 -->
|
||||
<section id="cap-5" class="guide-section">
|
||||
<h2><span class="num">5</span> Gap Analysis — le 10 misure dell'Art. 21</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> L'Art. 21 elenca 10 famiglie di "cose che devi avere".
|
||||
La Gap Analysis ti fa domande per ognuna e calcola quanto sei attrezzato. Non serve essere
|
||||
perfetti — serve <strong>sapere dove sei messo male</strong> e <strong>aver iniziato a migliorare</strong>.
|
||||
</div>
|
||||
|
||||
<p>Le 10 misure (semplificate):</p>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">a)</span> Politiche di analisi del rischio</h4>
|
||||
<p><em>Hai scritto come la tua azienda gestisce il rischio cyber?</em> Ti serve almeno una policy
|
||||
approvata dall'amministratore delegato che descriva il metodo (es. ISO 27005).</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">b)</span> Gestione degli incidenti</h4>
|
||||
<p><em>Sai cosa fare quando succede qualcosa?</em> Devi avere una procedura scritta che dica:
|
||||
chi viene chiamato, in che ordine, chi notifica al CSIRT, chi parla con i giornalisti.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">c)</span> Continuità operativa e gestione delle crisi</h4>
|
||||
<p><em>Se cade un server, in quanto tempo riparti?</em> RTO (tempo per ripartire) e RPO (quanti dati
|
||||
puoi perdere) vanno definiti e <strong>testati</strong> almeno una volta l'anno.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">d)</span> Sicurezza della supply chain</h4>
|
||||
<p><em>I tuoi fornitori sono sicuri?</em> Manda loro un questionario sicurezza,
|
||||
chiedi le loro certificazioni, metti clausole NIS2 nei contratti.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">e)</span> Sicurezza nell'acquisto, sviluppo e manutenzione</h4>
|
||||
<p><em>Quando comprate software, è sicuro?</em> Patch management, test prima del go-live, gestione
|
||||
vulnerabilità (CVE).</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">f)</span> Politiche di valutazione dell'efficacia</h4>
|
||||
<p><em>Misuri se le tue misure funzionano?</em> Audit periodici (interni o esterni), KPI cyber,
|
||||
revisione annuale del SoA (Statement of Applicability).</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">g)</span> Igiene cibernetica di base e formazione</h4>
|
||||
<p><em>I tuoi dipendenti sanno riconoscere un phishing?</em> Password manager, MFA, formazione
|
||||
almeno annuale per tutti i livelli.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">h)</span> Crittografia</h4>
|
||||
<p><em>I dati sensibili sono cifrati?</em> AES-256 per i dati a riposo, TLS 1.3 per i dati in
|
||||
transito. Gestione corretta delle chiavi.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">i)</span> Sicurezza del personale, controllo accessi e gestione asset</h4>
|
||||
<p><em>Chi può accedere a cosa?</em> Principio del minimo privilegio, revisione accessi 2 volte
|
||||
l'anno, offboarding rapido quando un dipendente esce.</p>
|
||||
</div>
|
||||
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">j)</span> Autenticazione a più fattori (MFA)</h4>
|
||||
<p><em>Hai MFA almeno su email, VPN, amministrazione?</em> Obbligatorio per tutti gli accessi
|
||||
critici. Anche SMS è meglio di nulla, app authenticator è meglio di SMS, hardware token (FIDO2)
|
||||
è il top.</p>
|
||||
</div>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio Aurora Sanità.</strong> Punteggio iniziale 58%. I gap principali sono in
|
||||
Supply Chain (40%) e Crittografia (50%). Piano di azione: assessment dei 12 fornitori critici
|
||||
entro 6 mesi, attivare TLS 1.3 ovunque e cifratura at-rest sui DB cartelle cliniche entro 9 mesi.
|
||||
</div>
|
||||
|
||||
<h3>Come si fa nella piattaforma</h3>
|
||||
<ol class="step-list">
|
||||
<li>Vai su <strong>Gap Analysis</strong> e clicca "Nuovo Assessment".</li>
|
||||
<li>Rispondi alle 80 domande (sei modalità: implementato / parziale / non implementato / non applicabile).</li>
|
||||
<li>Per ogni risposta, indica il <strong>livello di maturità</strong> (1–5).</li>
|
||||
<li>Salva: puoi continuare in più sessioni.</li>
|
||||
<li>Quando finisci, clicca <strong>"Analisi AI"</strong> per ricevere raccomandazioni prioritarie.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Cap 6 -->
|
||||
<section id="cap-6" class="guide-section">
|
||||
<h2><span class="num">6</span> Gestione dei Rischi</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Il "registro dei rischi" è un elenco di cose brutte che
|
||||
<em>potrebbero</em> accadere, con quanto è probabile e quanto farebbe male. Per ognuna decidi
|
||||
cosa fare: ridurla, accettarla, assicurarla, o eliminarla.
|
||||
</div>
|
||||
|
||||
<h3>La matrice 5×5</h3>
|
||||
<p>Ogni rischio ha due valori:</p>
|
||||
<ul>
|
||||
<li><strong>Probabilità (1–5)</strong>: 1 = quasi mai, 5 = quasi certo.</li>
|
||||
<li><strong>Impatto (1–5)</strong>: 1 = trascurabile, 5 = catastrofico.</li>
|
||||
</ul>
|
||||
<p>Il prodotto (probabilità × impatto) dà il <strong>punteggio di rischio</strong> da 1 a 25.
|
||||
Sopra 16 è critico, 9–15 alto, 4–8 medio, sotto 4 basso.</p>
|
||||
|
||||
<h3>Le quattro strategie di trattamento</h3>
|
||||
<ul>
|
||||
<li><strong>Mitigare</strong>: riduco probabilità o impatto (es. installo backup offsite per ridurre l'impatto di un ransomware).</li>
|
||||
<li><strong>Trasferire</strong>: passo il rischio a un altro (es. cyber-insurance).</li>
|
||||
<li><strong>Accettare</strong>: il rischio è basso, lo accetto consapevolmente (con firma del board).</li>
|
||||
<li><strong>Evitare</strong>: smetto di fare l'attività che genera il rischio.</li>
|
||||
</ul>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Rischio "Ransomware su sistema PACS" (Aurora Sanità) → probabilità 4,
|
||||
impatto 5 → punteggio inerente 20 (critico). Trattamento: mitigazione = MFA su RDP + backup
|
||||
immutabili + patch mensili. Dopo le misure: probabilità 2, impatto 4 → punteggio residuo 8 (medio).
|
||||
</div>
|
||||
|
||||
<h3>Come si fa nella piattaforma</h3>
|
||||
<ol class="step-list">
|
||||
<li>Vai su <strong>Rischi</strong> e clicca "Nuovo Rischio" (o "AI Suggerisci" per partire dai
|
||||
rischi tipici del tuo settore).</li>
|
||||
<li>Compila titolo, descrizione, categoria, minaccia e vulnerabilità.</li>
|
||||
<li>Imposta probabilità e impatto inerenti (1–5 ciascuno).</li>
|
||||
<li>Scegli la strategia di trattamento e descrivi le azioni concrete.</li>
|
||||
<li>Imposta probabilità e impatto residui (dopo le misure).</li>
|
||||
<li>Assegna un responsabile e una data di revisione.</li>
|
||||
</ol>
|
||||
|
||||
<div class="norm-box">
|
||||
<span class="article-tag">Art. 21 (2)(a)</span>
|
||||
Le politiche di analisi dei rischi e di sicurezza dei sistemi informatici devono essere documentate,
|
||||
approvate dagli organi di vertice, e aggiornate almeno una volta l'anno.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 7 -->
|
||||
<section id="cap-7" class="guide-section">
|
||||
<h2><span class="num">7</span> Incidenti — gli obblighi 24h / 72h / 30d</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Se hai un incidente "significativo" devi avvisare il CSIRT
|
||||
(la squadra nazionale di risposta cyber) in tre tappe: una prima allerta entro 24 ore, una
|
||||
notifica completa entro 72 ore, e un report finale entro 30 giorni.
|
||||
</div>
|
||||
|
||||
<h3>Quando un incidente è "significativo"?</h3>
|
||||
<p>Almeno uno di questi criteri:</p>
|
||||
<ul>
|
||||
<li>Ha colpito <strong>≥ 500 utenti</strong>.</li>
|
||||
<li>Ha bloccato i servizi per <strong>> 4 ore</strong>.</li>
|
||||
<li>Ha <strong>impatto transfrontaliero</strong> (altri Stati UE).</li>
|
||||
<li>È un <strong>cyber attack</strong> intenzionale (ransomware, DDoS, intrusione…).</li>
|
||||
<li>Ha generato danni economici diretti > <strong>100.000 €</strong>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>La timeline</h3>
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">24h</span> Early Warning</h4>
|
||||
<p>Prima segnalazione "veloce". Bastano: cosa è successo, sospetti di malevolenza, impatto preliminare.
|
||||
Non devi avere ancora tutte le risposte.</p>
|
||||
</div>
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">72h</span> Notifica completa</h4>
|
||||
<p>Aggiornamento dettagliato: indicatori di compromissione, sistemi colpiti, misure di contenimento
|
||||
applicate, stima impatto.</p>
|
||||
</div>
|
||||
<div class="pillar-card">
|
||||
<h4><span class="pillar-num">30d</span> Final Report</h4>
|
||||
<p>Analisi completa: root cause, azioni correttive, lezioni apprese, raccomandazioni per il futuro.</p>
|
||||
</div>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Aurora Sanità subisce un DDoS sul portale prenotazioni alle 09:00 di lunedì.
|
||||
Sono colpiti 8.500 utenti per 4 ore → significativo. Workflow: Early Warning entro martedì 09:00 →
|
||||
Notifica entro giovedì 09:00 → Final Report entro mercoledì 28 (30 giorni dopo).
|
||||
</div>
|
||||
|
||||
<h3>Come si fa nella piattaforma</h3>
|
||||
<ol class="step-list">
|
||||
<li>Vai su <strong>Incidenti</strong> e clicca "Registra Incidente".</li>
|
||||
<li>Compila titolo, classificazione, severità, ora di rilevazione.</li>
|
||||
<li>Il sistema calcola automaticamente le 3 scadenze (24h/72h/30d).</li>
|
||||
<li>Quando arriva il momento, usa i bottoni <strong>"Invia Early Warning"</strong>,
|
||||
<strong>"Invia Notifica"</strong> e <strong>"Invia Final Report"</strong>: le email partono verso
|
||||
l'indirizzo CSIRT configurato.</li>
|
||||
<li>Aggiorna lo stato dell'incidente (analisi → contenimento → eradicazione → recovery → chiuso).</li>
|
||||
<li>Compila root cause e lezioni apprese alla chiusura.</li>
|
||||
</ol>
|
||||
|
||||
<div class="norm-box">
|
||||
<span class="article-tag">Art. 23</span>
|
||||
Stabilisce gli obblighi di segnalazione degli incidenti significativi al CSIRT competente. Il mancato
|
||||
rispetto delle scadenze è sanzionabile fino al 2% del fatturato globale per le entità essenziali.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 8 -->
|
||||
<section id="cap-8" class="guide-section">
|
||||
<h2><span class="num">8</span> Policy e procedure</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Una "policy" è un documento scritto che dice <em>come</em>
|
||||
si fa una cosa in azienda. Serve sia per fare bene, sia per dimostrare alle autorità che ci hai pensato.
|
||||
</div>
|
||||
|
||||
<p>Le policy minime per NIS2:</p>
|
||||
<ol>
|
||||
<li>Politica di Sicurezza delle Informazioni (master).</li>
|
||||
<li>Procedura di Gestione Incidenti.</li>
|
||||
<li>Politica di Continuità Operativa (BCP/DR).</li>
|
||||
<li>Politica di Controllo Accessi.</li>
|
||||
<li>Politica di Crittografia.</li>
|
||||
<li>Politica Supply Chain.</li>
|
||||
<li>Politica Vulnerability Management.</li>
|
||||
<li>Acceptable Use Policy (per i dipendenti).</li>
|
||||
</ol>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Genera con l'AI la bozza della "Politica di Crittografia": clicchi
|
||||
"Genera con AI", indichi il settore (sanità), l'AI ti propone un documento di 4–5 pagine con
|
||||
sezioni standard (algoritmi accettati, gestione chiavi, KMS, audit). Tu rivedi, adatti e fai
|
||||
approvare dal CDA. Il sistema marca la versione, la data di approvazione e la prossima revisione.
|
||||
</div>
|
||||
|
||||
<h3>Stati di una policy</h3>
|
||||
<p>Bozza → In revisione → Approvata → Pubblicata → Archiviata (quando sostituita).</p>
|
||||
</section>
|
||||
|
||||
<!-- Cap 9 -->
|
||||
<section id="cap-9" class="guide-section">
|
||||
<h2><span class="num">9</span> Supply Chain (Fornitori)</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Se un tuo fornitore IT viene bucato, l'attaccante può entrare
|
||||
in casa tua. NIS2 ti obbliga a valutare i fornitori "critici" (quelli con accesso ai tuoi sistemi
|
||||
o dati sensibili).
|
||||
</div>
|
||||
|
||||
<h3>Cosa fare per ogni fornitore critico</h3>
|
||||
<ol>
|
||||
<li>Inserirlo nell'anagrafica con dati di contratto.</li>
|
||||
<li>Classificare la criticità (low/medium/high/critical).</li>
|
||||
<li>Inviargli il questionario sicurezza (40+ domande standard).</li>
|
||||
<li>Verificare la risposta e assegnare un risk score (0–10).</li>
|
||||
<li>Rinnovare la valutazione almeno una volta l'anno.</li>
|
||||
<li>Inserire clausole NIS2 nel contratto (right to audit, notifica incidenti, etc.).</li>
|
||||
</ol>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> "MedTech Manutenzione PACS S.r.l." ha accesso remoto al PACS di
|
||||
Aurora Sanità → criticità "critical". Risk score iniziale 6/10 (ha ISO 27001, ma sub-appalto
|
||||
non chiaro). Azione: clausola contrattuale che vieta sub-appalto senza autorizzazione e
|
||||
impone notifica incidenti entro 24h.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 10 -->
|
||||
<section id="cap-10" class="guide-section">
|
||||
<h2><span class="num">10</span> Formazione (Art. 20)</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> NIS2 è chiara: <em>gli amministratori (CDA, direttori)
|
||||
devono essere formati sulla cybersecurity</em>. Non è un nice-to-have, è obbligatorio. E sono
|
||||
loro che rispondono in prima persona se l'azienda non è conforme.
|
||||
</div>
|
||||
|
||||
<h3>Chi formare</h3>
|
||||
<ol>
|
||||
<li><strong>Organi di vertice</strong> (CDA, amministratori): formazione specifica annuale.</li>
|
||||
<li><strong>Personale IT/sicurezza</strong>: formazione tecnica continua.</li>
|
||||
<li><strong>Tutto il personale</strong>: awareness training (phishing, password, segnalazioni)
|
||||
almeno annuale.</li>
|
||||
</ol>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Aurora Sanità organizza: 1 corso da 4h per CDA, 1 corso da 16h per il team
|
||||
IT, una sessione obbligatoria di 1h per tutti i dipendenti più simulazioni di phishing trimestrali.
|
||||
Tutto loggato nel modulo Formazione.
|
||||
</div>
|
||||
|
||||
<h3>Come si fa nella piattaforma</h3>
|
||||
<ol class="step-list">
|
||||
<li>Vai su <strong>Formazione</strong> → "Nuovo Corso".</li>
|
||||
<li>Definisci titolo, contenuti, durata, ruoli target.</li>
|
||||
<li>Assegna il corso ai dipendenti (singoli o per gruppo).</li>
|
||||
<li>Imposta scadenza e prerequisiti (es. obbligatorio prima dell'onboarding).</li>
|
||||
<li>Monitora il completamento dal cruscotto "Compliance Status".</li>
|
||||
</ol>
|
||||
|
||||
<div class="norm-box">
|
||||
<span class="article-tag">Art. 20</span>
|
||||
Gli organi di gestione delle entità essenziali e importanti devono seguire una formazione che
|
||||
consenta loro di acquisire conoscenze e competenze sufficienti per individuare i rischi e valutare
|
||||
le pratiche di gestione del rischio di cibersicurezza.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 11 -->
|
||||
<section id="cap-11" class="guide-section">
|
||||
<h2><span class="num">11</span> Asset (cosa hai e cosa proteggi)</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Non puoi proteggere quello che non sai di avere.
|
||||
L'inventario asset elenca tutti i sistemi, applicazioni, database e infrastrutture critiche.
|
||||
</div>
|
||||
|
||||
<h3>Tipi di asset</h3>
|
||||
<ul>
|
||||
<li><strong>Hardware</strong>: server, PLC, dispositivi mobili.</li>
|
||||
<li><strong>Software</strong>: applicazioni gestionali, SCADA, CRM.</li>
|
||||
<li><strong>Network</strong>: firewall, router, switch.</li>
|
||||
<li><strong>Data</strong>: database, file storage, backup.</li>
|
||||
<li><strong>Service</strong>: portali web, API.</li>
|
||||
<li><strong>Personnel</strong>: utenti privilegiati, ruoli chiave.</li>
|
||||
<li><strong>Facility</strong>: data center, sale server.</li>
|
||||
</ul>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Aurora Sanità inserisce: il PACS, il database cartelle cliniche,
|
||||
il portale prenotazioni. Per ognuno: criticità, vendor, location, owner. Quando arriva un incidente
|
||||
o un rischio, può essere associato a uno o più asset → tracciabilità totale.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 12 -->
|
||||
<section id="cap-12" class="guide-section">
|
||||
<h2><span class="num">12</span> Audit & Report</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> Quando le autorità (ACN) verranno a chiederti conto della tua
|
||||
compliance, devi poter mostrare documenti, evidenze, registri. Qui li trovi tutti.
|
||||
</div>
|
||||
|
||||
<h3>Cosa puoi fare</h3>
|
||||
<ul>
|
||||
<li><strong>Controlli</strong>: lista dei 10 controlli Art. 21 + mapping ISO 27001 con stato di
|
||||
implementazione (0–100%).</li>
|
||||
<li><strong>Evidence Files</strong>: carica documenti probatori (verbali, screenshot, certificati).</li>
|
||||
<li><strong>Audit Log</strong>: registro immutabile di tutto quello che succede in piattaforma
|
||||
(utenti, modifiche, accessi). Garantito con catena hash SHA-256.</li>
|
||||
<li><strong>Report Esecutivo</strong>: PDF/HTML stampabile per il CDA.</li>
|
||||
<li><strong>Export CSV</strong>: rischi, incidenti, controlli, asset.</li>
|
||||
</ul>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Pre-audit ISO 27001: genera il report esecutivo, esporta in CSV
|
||||
rischi e controlli, scarica l'audit log certificato. L'auditor ha tutto in 5 minuti.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 13 -->
|
||||
<section id="cap-13" class="guide-section">
|
||||
<h2><span class="num">13</span> Segnalazioni interne (Whistleblowing)</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> NIS2 incoraggia (e in alcuni casi obbliga) ad avere un canale
|
||||
anonimo dove dipendenti e collaboratori possono segnalare problemi di sicurezza senza paura di
|
||||
ritorsioni.
|
||||
</div>
|
||||
|
||||
<h3>Tipi di segnalazione</h3>
|
||||
<ul>
|
||||
<li><strong>Whistleblowing</strong>: anonimo, comportamenti illeciti.</li>
|
||||
<li><strong>Feedback bug/UX</strong>: segnalazioni operative sulla piattaforma stessa,
|
||||
classificate dall'AI e (opzionalmente) risolte automaticamente.</li>
|
||||
</ul>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Esempio.</strong> Un dipendente nota che le credenziali admin del CRM sono salvate in
|
||||
un foglio Excel condiviso. Invia una segnalazione anonima → il responsabile compliance la riceve
|
||||
con codice di tracciamento → il dipendente può seguire lo stato senza rivelare la propria identità.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cap 14 -->
|
||||
<section id="cap-14" class="guide-section">
|
||||
<h2><span class="num">14</span> AI — come usarla bene</h2>
|
||||
|
||||
<div class="plain-box">
|
||||
<strong>In parole semplici.</strong> La piattaforma usa Claude (Anthropic) per assisterti. L'AI
|
||||
è bravissima a partire dal foglio bianco, ma <strong>non sostituisce</strong> il tuo giudizio:
|
||||
rivedi sempre quello che produce prima di approvarlo.
|
||||
</div>
|
||||
|
||||
<h3>Dove trovi l'AI</h3>
|
||||
<ul>
|
||||
<li><strong>Analisi Assessment</strong>: dopo aver completato la Gap Analysis, genera
|
||||
un'analisi delle priorità con raccomandazioni.</li>
|
||||
<li><strong>AI Suggerisci Rischi</strong>: parte dal tuo settore + asset e propone una lista
|
||||
di rischi tipici.</li>
|
||||
<li><strong>Genera Policy</strong>: bozza di policy a partire dalla categoria.</li>
|
||||
<li><strong>Classifica Incidente</strong>: dato un incidente, suggerisce severità e classificazione.</li>
|
||||
<li><strong>Knowledge Base RAG</strong>: chiedi all'AI partendo dai documenti che hai caricato.</li>
|
||||
<li><strong>Classificazione Feedback</strong>: tag automatico per le segnalazioni di bug/UX.</li>
|
||||
</ul>
|
||||
|
||||
<div class="example-box">
|
||||
<strong>Buona pratica.</strong> Quando l'AI suggerisce 8 rischi, non importarli tutti ciecamente.
|
||||
Scegli i 4 più rilevanti per il tuo contesto, scartane uno che è duplicato di un asset interno,
|
||||
e personalizza descrizione/probabilità per la tua realtà.
|
||||
</div>
|
||||
|
||||
<h3>Limiti che devi conoscere</h3>
|
||||
<ul>
|
||||
<li>L'AI non vede i tuoi dati grezzi: solo metadati anonimizzati (settore, range dipendenti,
|
||||
categoria asset).</li>
|
||||
<li>Le bozze di policy <strong>devono</strong> essere riviste da un umano competente prima
|
||||
della pubblicazione.</li>
|
||||
<li>L'AI non è un consulente legale né un sostituto del CISO.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Cap 15 -->
|
||||
<section id="cap-15" class="guide-section">
|
||||
<h2><span class="num">15</span> Glossario rapido</h2>
|
||||
<dl class="glossario">
|
||||
<dt><span class="acro">ACN</span> Agenzia per la Cybersicurezza Nazionale</dt>
|
||||
<dd>L'autorità italiana che vigila sulla NIS2 e raccoglie le notifiche di incidenti.</dd>
|
||||
|
||||
<dt><span class="acro">CSIRT</span> Computer Security Incident Response Team</dt>
|
||||
<dd>Squadra nazionale di risposta agli incidenti. Riceve le notifiche 24h/72h/30d.</dd>
|
||||
|
||||
<dt><span class="acro">ENISA</span> Agenzia UE per la Cybersicurezza</dt>
|
||||
<dd>Coordina a livello europeo le linee guida operative NIS2.</dd>
|
||||
|
||||
<dt><span class="acro">RTO</span> Recovery Time Objective</dt>
|
||||
<dd>Quanto tempo massimo puoi stare giù prima di tornare operativo. Esempio: RTO 4 ore.</dd>
|
||||
|
||||
<dt><span class="acro">RPO</span> Recovery Point Objective</dt>
|
||||
<dd>Quanti dati puoi perderti al massimo. Esempio: RPO 1 ora = backup almeno orari.</dd>
|
||||
|
||||
<dt><span class="acro">MFA</span> Multi-Factor Authentication</dt>
|
||||
<dd>Autenticazione a più fattori: password + codice OTP/app/hardware.</dd>
|
||||
|
||||
<dt><span class="acro">ISMS</span> Information Security Management System</dt>
|
||||
<dd>Sistema di gestione della sicurezza delle informazioni (es. ISO 27001).</dd>
|
||||
|
||||
<dt><span class="acro">SoA</span> Statement of Applicability</dt>
|
||||
<dd>Documento che elenca quali controlli ISO 27001 applichi e quali no, con motivazione.</dd>
|
||||
|
||||
<dt><span class="acro">BCP / DRP</span> Business Continuity Plan / Disaster Recovery Plan</dt>
|
||||
<dd>I piani per garantire la continuità operativa e il ripristino dopo un disastro.</dd>
|
||||
|
||||
<dt><span class="acro">SCADA</span> Supervisory Control And Data Acquisition</dt>
|
||||
<dd>Sistemi di controllo industriale (es. impianti di acqua, energia, manifattura).</dd>
|
||||
|
||||
<dt><span class="acro">PACS</span> Picture Archiving and Communication System</dt>
|
||||
<dd>Sistema sanitario che archivia le immagini diagnostiche (TAC, RMN, ecografie).</dd>
|
||||
|
||||
<dt><span class="acro">TMS</span> Transport Management System</dt>
|
||||
<dd>Software che gestisce le spedizioni e la flotta in azienda di logistica.</dd>
|
||||
|
||||
<dt><span class="acro">DDoS</span> Distributed Denial of Service</dt>
|
||||
<dd>Attacco che satura un servizio con traffico inutile per metterlo offline.</dd>
|
||||
|
||||
<dt><span class="acro">BEC</span> Business Email Compromise</dt>
|
||||
<dd>Frode via email che impersona dirigenti per ottenere bonifici fraudolenti.</dd>
|
||||
|
||||
<dt><span class="acro">NCR / CAPA</span> Non-Conformity Report / Corrective and Preventive Action</dt>
|
||||
<dd>Non conformità (es. da audit) e relative azioni correttive/preventive.</dd>
|
||||
|
||||
<dt><span class="acro">RAG</span> Retrieval-Augmented Generation</dt>
|
||||
<dd>Tecnica AI che fa rispondere l'assistente partendo da documenti caricati nella Knowledge Base.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div class="card" style="margin-top:40px;">
|
||||
<div class="card-body" style="text-align:center;">
|
||||
<p style="margin:0;color:var(--text-muted,#6b7280);">
|
||||
Per dubbi specifici, in ogni pagina trovi il pulsante <strong>?</strong> in alto a destra
|
||||
che apre la guida contestuale di quella sezione. Buon lavoro!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/help.js"></script>
|
||||
<script>
|
||||
// La guida è accessibile anche da non autenticati (utile per onboarding)
|
||||
// ma se sei loggato carichi sidebar + i18n normalmente.
|
||||
if (typeof loadSidebar === 'function') loadSidebar();
|
||||
if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1268
public/index-en.html
Normal file
1268
public/index-en.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NIS2 Agile — Compliance NIS2 semplificata per aziende e consulenti</title>
|
||||
<meta name="description" content="Piattaforma SaaS multi-tenant per la compliance alla Direttiva NIS2 (EU 2022/2555) e D.Lgs. 138/2024. Gap analysis AI, risk management, incident response Art.23, policy, formazione.">
|
||||
<link rel="canonical" href="https://nis2.agile.software/">
|
||||
<link rel="alternate" hreflang="it" href="https://nis2.agile.software/">
|
||||
<link rel="alternate" hreflang="en" href="https://nis2.agile.software/index-en.html">
|
||||
<link rel="alternate" hreflang="x-default" href="https://nis2.agile.software/">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
@ -623,6 +627,11 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── NAV TOGGLE (hamburger mobile ≤768px) ── */
|
||||
.nav-toggle { display: none; background: none; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; cursor: pointer; padding: 8px 10px; color: var(--text-white); min-height: 40px; min-width: 40px; }
|
||||
.nav-toggle:hover { background: rgba(255,255,255,0.08); border-color: var(--cyan); }
|
||||
.nav-toggle svg { display: block; }
|
||||
|
||||
/* ── RESPONSIVE ── */
|
||||
@media (max-width: 900px) {
|
||||
nav { padding: 0 20px; }
|
||||
@ -637,11 +646,28 @@
|
||||
.steps-wrap::before { display: none; }
|
||||
.lg231-card { flex-direction: column; padding: 28px; }
|
||||
.footer-inner { flex-direction: column; align-items: flex-start; }
|
||||
.nav-actions .btn-ghost { display: none; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-box { padding: 28px 20px; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.nav-actions { display: none; }
|
||||
.nav-actions.open {
|
||||
display: flex; flex-direction: column; align-items: stretch;
|
||||
position: fixed; top: 64px; left: 0; right: 0;
|
||||
background: rgba(15,23,42,0.97); backdrop-filter: blur(16px);
|
||||
padding: 20px; gap: 12px; z-index: 99;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: calc(100vh - 64px); overflow-y: auto;
|
||||
}
|
||||
.nav-actions.open .btn, .nav-actions.open .lang-switch-nis { width: 100%; justify-content: center; }
|
||||
.nav-actions.open .lang-switch-nis { border-left: none; padding-left: 0; margin-left: 0; }
|
||||
.nav-actions.open .evix-suite-pill { margin: 0; width: 100%; justify-content: center; }
|
||||
.nav-toggle { display: inline-flex; align-items: center; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="mobile-conversion.css?v=20260519a">
|
||||
<!-- Plausible Analytics -->
|
||||
<script defer data-domain="nis2.agile.software" src="https://analytics.agile.software/js/script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -651,14 +677,27 @@
|
||||
<div class="nav-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
||||
<span class="nav-name">NIS2 <span>Agile</span></span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<a href="https://evix.agile.software" target="_blank" rel="noopener" class="evix-suite-pill" title="Parte della EViX Suite — Scopri tutti i moduli" style="display:inline-flex;align-items:center;gap:8px;padding:7px 14px;background:linear-gradient(135deg,rgba(227,30,36,0.18),rgba(227,30,36,0.08));border:1.5px solid rgba(227,30,36,0.5);color:#FCA5A5;border-radius:100px;font-size:12px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;text-decoration:none;transition:all .2s;white-space:nowrap;margin-left:auto;margin-right:14px;">
|
||||
<i class="fa-solid fa-layer-group" style="font-size:11px"></i>
|
||||
<span class="evix-pill-label">EViX Suite</span>
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:9px;opacity:.75"></i>
|
||||
</a>
|
||||
<div class="nav-actions" id="navActions">
|
||||
<a href="/login.html" class="btn btn-ghost btn-sm">
|
||||
<i class="fa-solid fa-right-to-bracket"></i> Accedi
|
||||
</a>
|
||||
<a href="#richiedi-accesso" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-envelope"></i> Richiedi accesso
|
||||
</a>
|
||||
<div class="lang-switch-nis" style="display:flex;align-items:center;gap:4px;padding-left:10px;border-left:1px solid rgba(255,255,255,0.15);margin-left:6px;">
|
||||
<a href="index.html" class="active" hreflang="it" style="color:#EF4444;text-decoration:none;padding:4px 8px;border-radius:5px;font-weight:700;font-size:12px;letter-spacing:0.04em;background:rgba(239,68,68,0.12);">IT</a>
|
||||
<span style="color:rgba(255,255,255,0.3);font-size:12px;">|</span>
|
||||
<a href="index-en.html" hreflang="en" style="color:#CBD5E1;text-decoration:none;padding:4px 8px;border-radius:5px;font-weight:700;font-size:12px;letter-spacing:0.04em;">EN</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-toggle" onclick="document.getElementById('navActions').classList.toggle('open')" aria-label="Menu">
|
||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
@ -666,6 +705,14 @@
|
||||
<div class="container">
|
||||
<div class="hero-wrap">
|
||||
<div class="hero-left">
|
||||
<a href="https://evix.agile.software" target="_blank" rel="noopener"
|
||||
style="display:inline-flex;align-items:center;gap:8px;background:rgba(227,30,36,0.1);border:1px solid rgba(227,30,36,0.35);color:#E31E24;font-size:12.5px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;padding:7px 14px;border-radius:100px;text-decoration:none;margin-bottom:14px;transition:all .2s;"
|
||||
onmouseover="this.style.background='rgba(227,30,36,0.18)';this.style.borderColor='#E31E24'"
|
||||
onmouseout="this.style.background='rgba(227,30,36,0.1)';this.style.borderColor='rgba(227,30,36,0.35)'">
|
||||
<i class="fa-solid fa-layer-group" style="font-size:11px"></i>
|
||||
Modulo della EViX Suite
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:10px;opacity:.8"></i>
|
||||
</a>
|
||||
<div class="hero-badge">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
D.Lgs. 138/2024 — In vigore dal 16 ottobre 2024
|
||||
@ -1013,12 +1060,23 @@
|
||||
<textarea name="messaggio" placeholder="Descrivi brevemente la tua esigenza o il settore di appartenenza NIS2..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full" style="margin-top:4px">
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;cursor:pointer;font-weight:400;font-size:13px;color:var(--text-light)">
|
||||
<input type="checkbox" name="consent" id="ahubConsent" required style="margin-top:4px;flex-shrink:0;accent-color:var(--cyan)">
|
||||
<span>Acconsento al trattamento dei miei dati ai sensi del GDPR (art. 13). I dati saranno usati esclusivamente per rispondere alla richiesta. <a href="https://agile.software/privacy" target="_blank" style="color:var(--cyan)">Privacy policy</a> <span style="color:var(--cyan)">*</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Honeypot anti-bot -->
|
||||
<div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">
|
||||
<label>Lascia vuoto</label>
|
||||
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary form-submit" id="submitBtn">
|
||||
<i class="fa-solid fa-paper-plane"></i> Invia richiesta
|
||||
</button>
|
||||
<p class="form-note">
|
||||
<i class="fa-solid fa-lock" style="color:var(--cyan);margin-right:4px;font-size:11px;"></i>
|
||||
I tuoi dati sono trattati nel rispetto del GDPR. Nessuna cessione a terzi.
|
||||
Risposta entro 24h lavorative · Powered by AgileHub
|
||||
</p>
|
||||
</form>
|
||||
<div class="form-success" id="formSuccess">
|
||||
@ -1048,6 +1106,9 @@
|
||||
<a href="/login.html" class="btn btn-ghost btn-lg">
|
||||
<i class="fa-solid fa-right-to-bracket"></i> Accedi
|
||||
</a>
|
||||
<a href="https://evix.agile.software" target="_blank" rel="noopener" class="btn btn-ghost btn-lg" style="border-color:rgba(227,30,36,0.45);color:#E31E24">
|
||||
<i class="fa-solid fa-layer-group"></i> Scopri tutta la EViX Suite
|
||||
</a>
|
||||
</div>
|
||||
<p class="cta-note">Sei un consulente o MSSP? Il form di richiesta ti permette di attivare accesso per il tuo intero portfolio clienti.</p>
|
||||
</div>
|
||||
@ -1065,7 +1126,13 @@
|
||||
<div class="footer-links">
|
||||
<a href="#richiedi-accesso">Richiedi accesso</a>
|
||||
<a href="/login.html">Accedi</a>
|
||||
<a href="https://lg231.agile.software/" target="_blank">231 Agile</a>
|
||||
<a href="https://evix.agile.software" target="_blank" rel="noopener" style="color:#E31E24;font-weight:600">EViX Suite ↗</a>
|
||||
<a href="https://lg231.agile.software/" target="_blank" rel="noopener">231 Agile</a>
|
||||
<a href="https://qsa.agile.software/" target="_blank" rel="noopener">Agile QSA</a>
|
||||
<a href="https://trpg.agile.software/" target="_blank" rel="noopener">TRPG Agile</a>
|
||||
<a href="https://sustainai.agile.software/" target="_blank" rel="noopener">SustainAI</a>
|
||||
<a href="https://platform.agile.software/" target="_blank" rel="noopener" style="color:#A78BFA;font-weight:600">Built on Agile Platform ↗</a>
|
||||
<a href="https://agile.software/" target="_blank" rel="noopener">Agile Technology</a>
|
||||
<a href="mailto:info@agile.software">info@agile.software</a>
|
||||
</div>
|
||||
<div class="footer-copy">
|
||||
@ -1075,6 +1142,39 @@
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* AgileHub Lead Pipeline — NIS2 Agile (tenant agile-technology) */
|
||||
const AHUB_PK = '02fd04c434fb5e1a83d11ee001f88e2677e8660f';
|
||||
const AHUB_ENDPOINT = 'https://agilehub.agile.software/api/public/applets/' + AHUB_PK + '/submit';
|
||||
|
||||
/* Provenance tracking */
|
||||
let __scrollMax = 0;
|
||||
window.addEventListener('scroll', () => {
|
||||
const h = document.documentElement;
|
||||
const pct = Math.round((h.scrollTop || window.scrollY) / (h.scrollHeight - h.clientHeight) * 100);
|
||||
if (pct > __scrollMax) __scrollMax = Math.min(pct, 100);
|
||||
}, { passive: true });
|
||||
const __pageLoadedAt = Date.now();
|
||||
|
||||
function ahubMetadata() {
|
||||
const u = new URLSearchParams(location.search);
|
||||
const md = {
|
||||
utm_source: u.get('utm_source') || null,
|
||||
utm_medium: u.get('utm_medium') || null,
|
||||
utm_campaign: u.get('utm_campaign') || null,
|
||||
utm_content: u.get('utm_content') || null,
|
||||
utm_term: u.get('utm_term') || null,
|
||||
referrer: document.referrer || null,
|
||||
page_title: document.title,
|
||||
page_url: location.href,
|
||||
scroll_depth_pct: __scrollMax,
|
||||
time_on_page_sec: Math.round((Date.now() - __pageLoadedAt) / 1000),
|
||||
viewport: window.innerWidth + 'x' + window.innerHeight,
|
||||
language: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
};
|
||||
return md;
|
||||
}
|
||||
|
||||
document.getElementById('inviteForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
@ -1082,35 +1182,52 @@ document.getElementById('inviteForm').addEventListener('submit', async function(
|
||||
const successEl = document.getElementById('formSuccess');
|
||||
const form = e.target;
|
||||
|
||||
/* Honeypot anti-bot — silent reject */
|
||||
if (form.website_url && form.website_url.value) {
|
||||
form.style.display = 'none';
|
||||
successEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.consent.checked) {
|
||||
errEl.textContent = 'È necessario accettare il trattamento dati per inviare la richiesta.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Invio in corso...';
|
||||
|
||||
const data = {
|
||||
const payload = {
|
||||
name: form.nome.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
phone: form.telefono.value.trim(),
|
||||
company: form.azienda.value.trim(),
|
||||
tipo: form.tipo.value,
|
||||
size: form.n_dipendenti.value,
|
||||
product_interest: form.interesse.value,
|
||||
source: 'nis2-landing',
|
||||
notes: form.messaggio.value.trim()
|
||||
consent: true,
|
||||
fields: {
|
||||
n_dipendenti: form.n_dipendenti.value,
|
||||
interesse: form.interesse.value,
|
||||
messaggio: form.messaggio.value.trim()
|
||||
},
|
||||
metadata: ahubMetadata()
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mktg-lead/submit', {
|
||||
const res = await fetch(AHUB_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (res.ok && json.success) {
|
||||
if (res.ok && json.success !== false) {
|
||||
form.style.display = 'none';
|
||||
successEl.style.display = 'block';
|
||||
} else {
|
||||
errEl.textContent = json.message || 'Errore nell\'invio. Riprova o scrivici a info@agile.software.';
|
||||
const code = json.error && json.error.code ? ' (' + json.error.code + ')' : '';
|
||||
errEl.textContent = (json.error && json.error.message) || ('Errore nell\'invio' + code + '. Scrivici a info@agile.software.');
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> Invia richiesta';
|
||||
@ -1123,5 +1240,19 @@ document.getElementById('inviteForm').addEventListener('submit', async function(
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Mobile conversion layer — sticky CTA + trust ribbon -->
|
||||
<script src="mobile-conversion.js?v=20260519a"
|
||||
data-primary-text="Richiedi accesso"
|
||||
data-primary-action="anchor"
|
||||
data-primary-href="#richiedi-accesso"
|
||||
data-primary-icon="→"
|
||||
data-accent="#EF4444"
|
||||
data-accent2="#DC2626"
|
||||
data-trust-items='["D.Lgs. 138/2024","Gap analysis AI","Incident response Art.23","Multi-tenant SaaS"]'
|
||||
data-trust-after="section#hero"
|
||||
data-trust-theme="dark"
|
||||
data-scroll-trigger="400"
|
||||
defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
548
public/js/ai-assistant.js
Normal file
548
public/js/ai-assistant.js
Normal file
@ -0,0 +1,548 @@
|
||||
/**
|
||||
* AgileHub — AI Assistant Widget (L1 Self-Service)
|
||||
* Embeddabile in qualsiasi prodotto della suite.
|
||||
*
|
||||
* Flusso: L1 AI (KB search) -> feedback -> L2 Formatore -> L3 Ticket
|
||||
*
|
||||
* Uso:
|
||||
* <script src="js/ai-assistant.js"
|
||||
* data-product="TRPG"
|
||||
* data-tenant-id="3"
|
||||
* data-api-url="https://agilehub.agile.software"
|
||||
* data-user-name="Mario Rossi"
|
||||
* data-user-email="mario@studio.it"
|
||||
* data-user-role="consultant"
|
||||
* data-lang="it">
|
||||
* </script>
|
||||
*
|
||||
* Prerequisiti backend:
|
||||
* - POST /api/support-sessions (crea sessione)
|
||||
* - POST /api/support-sessions/:id/message (AI risponde con KB)
|
||||
* - POST /api/support-sessions/:id/resolve (feedback positivo)
|
||||
* - POST /api/support-sessions/:id/forward-expert (escala L2)
|
||||
* - POST /api/support-sessions/:id/escalate (escala L3 ticket)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ==================== CONFIG ====================
|
||||
var script = document.currentScript || document.querySelector('script[data-product][data-api-url]');
|
||||
var CFG = {
|
||||
apiUrl: (script && script.dataset.apiUrl) || '',
|
||||
product: (script && script.dataset.product) || 'GENERIC',
|
||||
tenantId: (script && script.dataset.tenantId) || '1',
|
||||
userName: (script && script.dataset.userName) || '',
|
||||
userEmail: (script && script.dataset.userEmail) || '',
|
||||
userRole: (script && script.dataset.userRole) || '',
|
||||
lang: (script && script.dataset.lang) || 'it'
|
||||
};
|
||||
|
||||
var LABELS = {
|
||||
it: {
|
||||
title: 'Assistente AgileHub',
|
||||
placeholder: 'Scrivi una domanda...',
|
||||
viewing: 'Stai guardando',
|
||||
suggestions_intro: 'Posso aiutarti con:',
|
||||
feedback_yes: 'Si, grazie',
|
||||
feedback_no: 'No, aiuto',
|
||||
escalation_title: 'Come posso aiutarti meglio?',
|
||||
btn_rephrase: 'Riformula la domanda',
|
||||
btn_expert: 'Chiedi al formatore',
|
||||
btn_bug: 'Segnala un bug',
|
||||
expert_sent: 'Domanda inoltrata al formatore. Riceverai una notifica quando la risposta sara pronta.',
|
||||
expert_time: 'Tempo stimato: entro 4 ore lavorative.',
|
||||
resolved_thanks: 'Grazie per il feedback! La tua valutazione migliora l\'assistente.',
|
||||
preparing: 'Mi sto preparando...',
|
||||
thinking: 'Sto pensando...',
|
||||
source: 'Fonte',
|
||||
resume: 'Riprendiamo la conversazione.',
|
||||
mic_unsupported: 'Microfono non supportato dal browser.',
|
||||
error_generic: 'Errore di connessione. Riprova tra poco.'
|
||||
},
|
||||
en: {
|
||||
title: 'AgileHub Assistant',
|
||||
placeholder: 'Ask a question...',
|
||||
viewing: 'You are viewing',
|
||||
suggestions_intro: 'I can help you with:',
|
||||
feedback_yes: 'Yes, thanks',
|
||||
feedback_no: 'No, help me',
|
||||
escalation_title: 'How can I help you better?',
|
||||
btn_rephrase: 'Rephrase question',
|
||||
btn_expert: 'Ask an expert',
|
||||
btn_bug: 'Report a bug',
|
||||
expert_sent: 'Your question has been forwarded to an expert. You\'ll be notified when the answer is ready.',
|
||||
expert_time: 'Estimated time: within 4 business hours.',
|
||||
resolved_thanks: 'Thanks for the feedback! Your rating improves the assistant.',
|
||||
preparing: 'Preparing...',
|
||||
thinking: 'Thinking...',
|
||||
source: 'Source',
|
||||
resume: 'Let\'s pick up where we left off.',
|
||||
mic_unsupported: 'Microphone not supported by this browser.',
|
||||
error_generic: 'Connection error. Please try again shortly.'
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
var lang = CFG.lang;
|
||||
return (LABELS[lang] && LABELS[lang][key]) || (LABELS.it[key]) || key;
|
||||
}
|
||||
|
||||
// ==================== STATE ====================
|
||||
var sessionId = null;
|
||||
var messages = []; // { role: 'user'|'assistant'|'system', content, sources?, feedbackShown? }
|
||||
var panelOpen = false;
|
||||
var recognition = null;
|
||||
var isRecording = false;
|
||||
|
||||
// ==================== API HELPERS ====================
|
||||
function apiHeaders() {
|
||||
var h = { 'Content-Type': 'application/json', 'x-tenant-id': CFG.tenantId };
|
||||
// Try to get JWT from the host app
|
||||
if (typeof Api !== 'undefined' && Api.auth && Api.auth.getToken) {
|
||||
var tok = Api.auth.getToken();
|
||||
if (tok) h['Authorization'] = 'Bearer ' + tok;
|
||||
}
|
||||
var stored = typeof localStorage !== 'undefined' ? localStorage.getItem('nexus_token') : null;
|
||||
if (!h['Authorization'] && stored) h['Authorization'] = 'Bearer ' + stored;
|
||||
return h;
|
||||
}
|
||||
|
||||
function apiPost(path, body) {
|
||||
return fetch(CFG.apiUrl + path, {
|
||||
method: 'POST', headers: apiHeaders(), body: JSON.stringify(body)
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function apiGet(path) {
|
||||
return fetch(CFG.apiUrl + path, { headers: apiHeaders() }).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
// ==================== CONTEXT DETECTION ====================
|
||||
function detectPage() {
|
||||
// Try HelpSystem if available
|
||||
if (typeof HelpSystem !== 'undefined' && HelpSystem._detectPage) {
|
||||
return HelpSystem._detectPage();
|
||||
}
|
||||
// Fallback: extract from URL hash or path
|
||||
var hash = location.hash.replace('#', '').replace('/', '') || '';
|
||||
return hash || document.title || '';
|
||||
}
|
||||
|
||||
function getContextualSuggestions() {
|
||||
if (typeof HelpSystem === 'undefined') return [];
|
||||
var page = typeof HelpSystem._detectPage === 'function' ? HelpSystem._detectPage() : null;
|
||||
var helpData = null;
|
||||
if (page && HelpSystem._helpContent) helpData = HelpSystem._helpContent[page];
|
||||
if (page && HelpSystem._help) {
|
||||
var raw = HelpSystem._help[page];
|
||||
if (raw) helpData = raw[CFG.lang] || raw.it || raw;
|
||||
}
|
||||
if (!helpData) return [];
|
||||
|
||||
var suggestions = [];
|
||||
if (helpData.faq) {
|
||||
suggestions = helpData.faq.slice(0, 3).map(function (f) {
|
||||
return (typeof f === 'string') ? f : (f.question || f.q || '');
|
||||
});
|
||||
} else if (helpData.sections) {
|
||||
suggestions = helpData.sections.slice(0, 3).map(function (s) {
|
||||
if (!s.heading) return '';
|
||||
if (typeof s.heading === 'object') return s.heading[CFG.lang] || s.heading.it || '';
|
||||
return s.heading;
|
||||
});
|
||||
}
|
||||
return suggestions.filter(Boolean);
|
||||
}
|
||||
|
||||
// ==================== SESSION MANAGEMENT ====================
|
||||
function createSession(cb) {
|
||||
apiPost('/api/support-sessions', {
|
||||
product: CFG.product,
|
||||
callerEmail: CFG.userEmail,
|
||||
callerName: CFG.userName
|
||||
}).then(function (res) {
|
||||
if (res.success && res.data) {
|
||||
sessionId = res.data.sessionId || res.data.id;
|
||||
if (res.data.welcomeMessage) {
|
||||
messages.push({ role: 'assistant', content: res.data.welcomeMessage });
|
||||
}
|
||||
// Store session for resume
|
||||
try { sessionStorage.setItem('agilehub_ai_session_' + CFG.product, sessionId); } catch (e) {}
|
||||
}
|
||||
if (cb) cb();
|
||||
}).catch(function () {
|
||||
messages.push({ role: 'system', content: t('error_generic') });
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
|
||||
function tryResumeSession(cb) {
|
||||
try {
|
||||
var stored = sessionStorage.getItem('agilehub_ai_session_' + CFG.product);
|
||||
if (stored) {
|
||||
sessionId = stored;
|
||||
messages.push({ role: 'system', content: t('resume') });
|
||||
cb(true);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
cb(false);
|
||||
}
|
||||
|
||||
function sendMessage(text, cb) {
|
||||
if (!sessionId) {
|
||||
createSession(function () { if (sessionId) sendMessage(text, cb); else cb(); });
|
||||
return;
|
||||
}
|
||||
messages.push({ role: 'user', content: text });
|
||||
render();
|
||||
|
||||
apiPost('/api/support-sessions/' + sessionId + '/message', {
|
||||
content: text
|
||||
}).then(function (res) {
|
||||
if (res.success && res.data) {
|
||||
var reply = res.data.reply || res.data.message || res.data.answer || '';
|
||||
var sources = res.data.knowledgeArticles || res.data.sources || [];
|
||||
messages.push({ role: 'assistant', content: reply, sources: sources, feedbackShown: true });
|
||||
} else {
|
||||
messages.push({ role: 'assistant', content: (res.error && res.error.message) || t('error_generic') });
|
||||
}
|
||||
cb();
|
||||
}).catch(function () {
|
||||
messages.push({ role: 'assistant', content: t('error_generic') });
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSession() {
|
||||
if (!sessionId) return;
|
||||
apiPost('/api/support-sessions/' + sessionId + '/resolve', {
|
||||
rating: 5, resolutionSummary: 'auto'
|
||||
});
|
||||
}
|
||||
|
||||
function forwardToExpert(question, context) {
|
||||
if (!sessionId) return;
|
||||
return apiPost('/api/support-sessions/' + sessionId + '/forward-expert', {
|
||||
question: question, context: context
|
||||
});
|
||||
}
|
||||
|
||||
function escalateToTicket(reason) {
|
||||
if (!sessionId) return;
|
||||
return apiPost('/api/support-sessions/' + sessionId + '/escalate', {
|
||||
reason: reason
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== VOICE INPUT ====================
|
||||
function initVoice() {
|
||||
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) return null;
|
||||
recognition = new SR();
|
||||
recognition.lang = CFG.lang === 'en' ? 'en-US' : 'it-IT';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = false;
|
||||
return recognition;
|
||||
}
|
||||
|
||||
function toggleMic() {
|
||||
if (!recognition) {
|
||||
if (!initVoice()) {
|
||||
messages.push({ role: 'system', content: t('mic_unsupported') });
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRecording) {
|
||||
recognition.stop();
|
||||
isRecording = false;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
var input = document.getElementById('aia-input');
|
||||
recognition.onresult = function (e) {
|
||||
var transcript = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
transcript += e.results[i][0].transcript;
|
||||
}
|
||||
if (input) input.value = transcript;
|
||||
if (e.results[e.results.length - 1].isFinal) {
|
||||
isRecording = false;
|
||||
render();
|
||||
if (transcript.trim()) submitInput(transcript.trim());
|
||||
}
|
||||
};
|
||||
recognition.onerror = function () { isRecording = false; render(); };
|
||||
recognition.onend = function () { isRecording = false; render(); };
|
||||
recognition.start();
|
||||
isRecording = true;
|
||||
render();
|
||||
}
|
||||
|
||||
// ==================== RENDERING ====================
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
return messages.map(function (m, idx) {
|
||||
var isUser = m.role === 'user';
|
||||
var isSystem = m.role === 'system';
|
||||
|
||||
var bubble = '<div style="margin-bottom:10px;display:flex;justify-content:'
|
||||
+ (isUser ? 'flex-end' : 'flex-start') + '">'
|
||||
+ '<div style="max-width:82%;padding:10px 14px;border-radius:14px;font-size:13px;line-height:1.5;'
|
||||
+ 'background:' + (isUser ? 'linear-gradient(135deg,#7C3AED,#3B82F6)' : isSystem ? '#FEF3C7' : '#f3f4f6')
|
||||
+ ';color:' + (isUser ? '#fff' : '#111827')
|
||||
+ ';white-space:pre-wrap">'
|
||||
+ esc(m.content);
|
||||
|
||||
// Sources
|
||||
if (m.sources && m.sources.length > 0) {
|
||||
bubble += '<div style="margin-top:8px;padding-top:6px;border-top:1px solid rgba(0,0,0,.1);font-size:11px;color:#6B7280">';
|
||||
m.sources.forEach(function (s, si) {
|
||||
bubble += '<div>' + t('source') + ' [' + (si + 1) + '] ' + esc(s.question || s.title || '') + '</div>';
|
||||
});
|
||||
bubble += '</div>';
|
||||
}
|
||||
|
||||
bubble += '</div></div>';
|
||||
|
||||
// Feedback buttons
|
||||
if (m.feedbackShown && m.role === 'assistant' && idx === messages.length - 1) {
|
||||
bubble += '<div id="aia-feedback" style="display:flex;gap:8px;justify-content:flex-start;margin-bottom:10px">'
|
||||
+ '<button onclick="window.__aiaFeedback(true)" style="padding:6px 14px;border-radius:8px;border:1px solid #D1D5DB;'
|
||||
+ 'background:#fff;cursor:pointer;font-size:12px;color:#059669">👍 ' + esc(t('feedback_yes')) + '</button>'
|
||||
+ '<button onclick="window.__aiaFeedback(false)" style="padding:6px 14px;border-radius:8px;border:1px solid #D1D5DB;'
|
||||
+ 'background:#fff;cursor:pointer;font-size:12px;color:#DC2626">👎 ' + esc(t('feedback_no')) + '</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
return bubble;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEscalation() {
|
||||
return '<div id="aia-escalation" style="background:#F9FAFB;border-radius:12px;padding:14px;margin-bottom:10px">'
|
||||
+ '<div style="font-weight:600;margin-bottom:10px;font-size:13px">' + esc(t('escalation_title')) + '</div>'
|
||||
+ '<button onclick="window.__aiaEscalate(\'rephrase\')" style="display:block;width:100%;text-align:left;padding:10px 12px;'
|
||||
+ 'border:1px solid #E5E7EB;border-radius:8px;background:#fff;cursor:pointer;margin-bottom:6px;font-size:12px">'
|
||||
+ '🔄 ' + esc(t('btn_rephrase')) + '</button>'
|
||||
+ '<button onclick="window.__aiaEscalate(\'expert\')" style="display:block;width:100%;text-align:left;padding:10px 12px;'
|
||||
+ 'border:1px solid #E5E7EB;border-radius:8px;background:#fff;cursor:pointer;margin-bottom:6px;font-size:12px">'
|
||||
+ '👨‍🏫 ' + esc(t('btn_expert')) + '</button>'
|
||||
+ '<button onclick="window.__aiaEscalate(\'bug\')" style="display:block;width:100%;text-align:left;padding:10px 12px;'
|
||||
+ 'border:1px solid #E5E7EB;border-radius:8px;background:#fff;cursor:pointer;font-size:12px">'
|
||||
+ '🐛 ' + esc(t('btn_bug')) + '</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = document.getElementById('aia-panel');
|
||||
if (!panel) return;
|
||||
|
||||
var page = detectPage();
|
||||
var suggestions = (messages.length === 0) ? getContextualSuggestions() : [];
|
||||
var showEscalation = panel.dataset.showEscalation === '1';
|
||||
|
||||
panel.innerHTML =
|
||||
// Header
|
||||
'<div style="padding:14px 16px;background:linear-gradient(135deg,#7C3AED,#3B82F6);color:#fff;'
|
||||
+ 'display:flex;justify-content:space-between;align-items:center;border-radius:16px 16px 0 0">'
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px">'
|
||||
+ '<i class="fa-solid fa-wand-magic-sparkles"></i> ' + esc(t('title'))
|
||||
+ '</div>'
|
||||
+ '<button onclick="window.__aiaToggle()" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer">×</button>'
|
||||
+ '</div>'
|
||||
|
||||
// Context bar
|
||||
+ (page ? '<div style="padding:8px 16px;background:#EDE9FE;font-size:11px;color:#6D28D9">'
|
||||
+ '📍 ' + esc(t('viewing')) + ': <strong>' + esc(page) + '</strong></div>' : '')
|
||||
|
||||
// Messages area
|
||||
+ '<div id="aia-msgs" style="flex:1;overflow-y:auto;padding:14px 16px;background:#fafafa;min-height:200px">'
|
||||
+ (messages.length === 0 && suggestions.length > 0
|
||||
? '<div style="color:#6B7280;font-size:13px;margin-bottom:12px">' + esc(t('suggestions_intro')) + '</div>'
|
||||
+ suggestions.map(function (s) {
|
||||
return '<button onclick="window.__aiaSuggest(\'' + esc(s).replace(/'/g, "\\'") + '\')" '
|
||||
+ 'style="display:block;width:100%;text-align:left;padding:8px 12px;margin-bottom:6px;'
|
||||
+ 'border:1px solid #E5E7EB;border-radius:8px;background:#fff;cursor:pointer;font-size:12px;color:#4B5563">'
|
||||
+ '💡 ' + esc(s) + '</button>';
|
||||
}).join('')
|
||||
: '')
|
||||
+ renderMessages()
|
||||
+ (showEscalation ? renderEscalation() : '')
|
||||
+ '</div>'
|
||||
|
||||
// Input bar
|
||||
+ '<form id="aia-form" onsubmit="return window.__aiaSubmit(event)" '
|
||||
+ 'style="display:flex;gap:6px;padding:10px;border-top:1px solid #E5E7EB;background:#fff;border-radius:0 0 16px 16px">'
|
||||
+ '<input id="aia-input" type="text" placeholder="' + esc(t('placeholder')) + '" autocomplete="off" '
|
||||
+ 'style="flex:1;padding:10px 12px;border:1px solid #D1D5DB;border-radius:10px;font-size:13px;font-family:inherit">'
|
||||
+ '<button type="button" onclick="window.__aiaMic()" style="padding:8px;border:none;border-radius:10px;'
|
||||
+ 'background:' + (isRecording ? '#EF4444' : '#F3F4F6') + ';cursor:pointer;font-size:16px" title="Voce">'
|
||||
+ (isRecording ? '⏹' : '🎤') + '</button>'
|
||||
+ '<button type="submit" style="padding:8px 14px;border:none;border-radius:10px;'
|
||||
+ 'background:linear-gradient(135deg,#7C3AED,#3B82F6);color:#fff;font-weight:600;cursor:pointer">'
|
||||
+ '<i class="fa-solid fa-paper-plane"></i></button>'
|
||||
+ '</form>';
|
||||
|
||||
// Scroll to bottom
|
||||
var msgsEl = document.getElementById('aia-msgs');
|
||||
if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ==================== HANDLERS ====================
|
||||
function submitInput(text) {
|
||||
if (!text) return;
|
||||
var input = document.getElementById('aia-input');
|
||||
if (input) input.value = '';
|
||||
var panel = document.getElementById('aia-panel');
|
||||
if (panel) panel.dataset.showEscalation = '0';
|
||||
|
||||
// Show thinking
|
||||
messages.push({ role: 'assistant', content: t('thinking') });
|
||||
render();
|
||||
messages.pop(); // remove thinking
|
||||
|
||||
sendMessage(text, function () { render(); });
|
||||
}
|
||||
|
||||
window.__aiaToggle = function () {
|
||||
panelOpen = !panelOpen;
|
||||
var panel = document.getElementById('aia-panel');
|
||||
var fab = document.getElementById('ai-chat-fab');
|
||||
if (panel) panel.style.display = panelOpen ? 'flex' : 'none';
|
||||
if (fab) fab.style.display = panelOpen ? 'none' : 'flex';
|
||||
if (panelOpen && messages.length === 0) {
|
||||
tryResumeSession(function (resumed) {
|
||||
if (!resumed) {
|
||||
createSession(function () { render(); });
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (panelOpen) render();
|
||||
};
|
||||
|
||||
window.__aiaSubmit = function (e) {
|
||||
e.preventDefault();
|
||||
var input = document.getElementById('aia-input');
|
||||
var text = (input && input.value || '').trim();
|
||||
if (text) submitInput(text);
|
||||
return false;
|
||||
};
|
||||
|
||||
window.__aiaSuggest = function (text) { submitInput(text); };
|
||||
window.__aiaMic = function () { toggleMic(); };
|
||||
|
||||
window.__aiaFeedback = function (positive) {
|
||||
var fbEl = document.getElementById('aia-feedback');
|
||||
if (fbEl) fbEl.remove();
|
||||
|
||||
if (positive) {
|
||||
resolveSession();
|
||||
messages.push({ role: 'system', content: t('resolved_thanks') });
|
||||
render();
|
||||
} else {
|
||||
// Show escalation options
|
||||
var panel = document.getElementById('aia-panel');
|
||||
if (panel) panel.dataset.showEscalation = '1';
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
window.__aiaEscalate = function (action) {
|
||||
var panel = document.getElementById('aia-panel');
|
||||
if (panel) panel.dataset.showEscalation = '0';
|
||||
|
||||
if (action === 'rephrase') {
|
||||
render();
|
||||
var input = document.getElementById('aia-input');
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'expert') {
|
||||
var lastUserMsg = '';
|
||||
var lastAiMsg = '';
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (!lastAiMsg && messages[i].role === 'assistant') lastAiMsg = messages[i].content;
|
||||
if (!lastUserMsg && messages[i].role === 'user') lastUserMsg = messages[i].content;
|
||||
if (lastUserMsg && lastAiMsg) break;
|
||||
}
|
||||
forwardToExpert(lastUserMsg, lastAiMsg).then(function () {
|
||||
messages.push({ role: 'system', content: t('expert_sent') + '\n' + t('expert_time') });
|
||||
render();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'bug') {
|
||||
var reason = '';
|
||||
for (var j = messages.length - 1; j >= 0; j--) {
|
||||
if (messages[j].role === 'user') { reason = messages[j].content; break; }
|
||||
}
|
||||
escalateToTicket(reason).then(function () {
|
||||
messages.push({ role: 'system', content: 'Ticket tecnico creato. Il team di sviluppo lo prendera in carico.' });
|
||||
render();
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== INIT ====================
|
||||
function init() {
|
||||
// Ensure FAB exists (may have been created by product's app.js)
|
||||
var fab = document.getElementById('ai-chat-fab');
|
||||
if (!fab) {
|
||||
fab = document.createElement('button');
|
||||
fab.id = 'ai-chat-fab';
|
||||
fab.setAttribute('aria-label', 'Chiedi ad ARIA');
|
||||
fab.style.cssText = 'position:fixed;bottom:24px;right:24px;width:56px;height:56px;'
|
||||
+ 'border-radius:50%;border:none;cursor:pointer;z-index:9998;'
|
||||
+ 'background:linear-gradient(135deg,#7C3AED,#3B82F6);color:#fff;'
|
||||
+ 'box-shadow:0 6px 20px rgba(124,58,237,.4);font-size:22px;'
|
||||
+ 'display:flex;align-items:center;justify-content:center';
|
||||
fab.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i>';
|
||||
document.body.appendChild(fab);
|
||||
} else {
|
||||
// Rimuovi il vecchio pannello AI del prodotto (evita doppia finestra)
|
||||
var oldPanel = document.getElementById('ai-chat-panel');
|
||||
if (oldPanel) oldPanel.remove();
|
||||
|
||||
// Clona il FAB per eliminare TUTTI gli addEventListener precedenti
|
||||
var cleanFab = fab.cloneNode(true);
|
||||
fab.parentNode.replaceChild(cleanFab, fab);
|
||||
fab = cleanFab;
|
||||
}
|
||||
fab.style.display = 'flex';
|
||||
fab.style.alignItems = 'center';
|
||||
fab.style.justifyContent = 'center';
|
||||
fab.onclick = window.__aiaToggle;
|
||||
|
||||
// Ensure panel exists
|
||||
var panel = document.getElementById('aia-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'aia-panel';
|
||||
panel.style.cssText = 'position:fixed;bottom:90px;right:24px;width:380px;max-height:560px;'
|
||||
+ 'background:#fff;border-radius:16px;box-shadow:0 12px 32px rgba(0,0,0,.2);'
|
||||
+ 'display:none;z-index:9999;overflow:hidden;flex-direction:column;'
|
||||
+ 'font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif';
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
panel.dataset.showEscalation = '0';
|
||||
}
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
870
public/js/bug-reporter.js
Normal file
870
public/js/bug-reporter.js
Normal file
@ -0,0 +1,870 @@
|
||||
/**
|
||||
* AgileHub — Bug Reporter Widget
|
||||
* Embeddabile in qualsiasi prodotto (AllTax, TRPG, DFM, WMS, ecc.)
|
||||
*
|
||||
* Features:
|
||||
* - Bottone 🐛 segnalazione bug (testo + voce)
|
||||
* - Ctrl+V paste screenshot dalla clipboard
|
||||
* - Bottone 📸 screenshot rapido della videata
|
||||
* - Drag & drop immagini
|
||||
* - Tab "Mie Segnalazioni" / "Risolte"
|
||||
* - Campanella 🔔 notifiche con badge
|
||||
*
|
||||
* Uso:
|
||||
* <script src="https://agilehub.agile.software/widgets/bug-reporter.js"
|
||||
* data-product="TRPG"
|
||||
* data-tenant-id="1"
|
||||
* data-api-url="https://agilehub.agile.software"
|
||||
* data-user-name="Marco Consulente"
|
||||
* data-user-email="marco@studio.it"
|
||||
* data-user-role="consultant">
|
||||
* </script>
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ==================== CONFIG ====================
|
||||
const script = document.currentScript || document.querySelector('script[data-product]');
|
||||
const WIDGET_VERSION = '1.4.15';
|
||||
const WIDGET_BUILD = '20260415d';
|
||||
const CFG = {
|
||||
apiUrl: (script && script.dataset.apiUrl) || 'http://localhost',
|
||||
apiKey: (script && script.dataset.apiKey) || '',
|
||||
product: (script && script.dataset.product) || '',
|
||||
tenantId: (script && script.dataset.tenantId) || '1',
|
||||
userName: (script && script.dataset.userName) || '',
|
||||
userEmail: (script && script.dataset.userEmail) || '',
|
||||
userRole: (script && script.dataset.userRole) || '',
|
||||
lang: (script && script.dataset.lang) || 'it'
|
||||
};
|
||||
// L'API è sempre via proxy /api (sia per la dashboard che per i prodotti esterni).
|
||||
const API = CFG.apiUrl + '/api';
|
||||
|
||||
// Headers standard per tutte le chiamate API
|
||||
function _headers(json) {
|
||||
const h = { 'x-tenant-id': CFG.tenantId };
|
||||
if (CFG.apiKey) h['X-API-Key'] = CFG.apiKey;
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
}
|
||||
|
||||
// Auto-detect utente dal contesto dell'app (se data-user-email non è nel tag)
|
||||
function _detectUser() {
|
||||
if (CFG.userEmail) return;
|
||||
try {
|
||||
// 1. sessionStorage 'user' (pattern TRPG — JSON con email/name/role)
|
||||
const userJson = sessionStorage.getItem('user');
|
||||
if (userJson) {
|
||||
const u = JSON.parse(userJson);
|
||||
if (u && u.email) { CFG.userEmail = u.email; CFG.userName = u.name || u.full_name || CFG.userName; CFG.userRole = u.role || CFG.userRole; return; }
|
||||
}
|
||||
// 2. api.getUser() (pattern TRPG/AllTax — globale)
|
||||
if (typeof api !== 'undefined') {
|
||||
const u = (typeof api.getUser === 'function' ? api.getUser() : api.user) || {};
|
||||
if (u.email) { CFG.userEmail = u.email; CFG.userName = u.name || u.full_name || CFG.userName; CFG.userRole = u.role || CFG.userRole; return; }
|
||||
}
|
||||
// 3. JWT decode da access_token / nexus_token
|
||||
const token = sessionStorage.getItem('access_token') || sessionStorage.getItem('nexus_token') || localStorage.getItem('nexus_token');
|
||||
if (token && token.includes('.')) {
|
||||
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
|
||||
if (payload.email) { CFG.userEmail = payload.email; CFG.userName = payload.name || payload.full_name || CFG.userName; CFG.userRole = payload.role || CFG.userRole; }
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
// Ritenta: lo script è nel HEAD, l'utente potrebbe non essere ancora loggato
|
||||
setTimeout(_detectUser, 500);
|
||||
setTimeout(_detectUser, 3000);
|
||||
setTimeout(_detectUser, 8000);
|
||||
|
||||
// ==================== STATE ====================
|
||||
let _attachments = [];
|
||||
let _notifCount = 0;
|
||||
let _notifPollTimer = null;
|
||||
let _supportSessionId = null;
|
||||
let _chatMessages = [];
|
||||
let _chatMode = false; // AI chat e nel widget separato ai-assistant.js
|
||||
let _chatLoading = false;
|
||||
|
||||
// ==================== STYLES ====================
|
||||
const STYLES = `
|
||||
#nx-bar{position:fixed;bottom:94px;right:24px;display:flex;flex-direction:column;gap:14px;z-index:99990;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||||
#nx-bar button{width:56px;height:56px;border-radius:50%;border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,.25);transition:transform .15s}
|
||||
#nx-bar button:hover{transform:scale(1.1)}
|
||||
#nx-bug-btn{background:#ef4444;color:#fff}
|
||||
#nx-bell-btn{background:#3b82f6;color:#fff;position:relative}
|
||||
#nx-bell-badge{position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border-radius:10px;min-width:18px;height:18px;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 4px}
|
||||
#nx-modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99991;display:flex;align-items:center;justify-content:center}
|
||||
#nx-modal{background:#fff;border-radius:14px;width:92%;max-width:500px;max-height:92vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.3)}
|
||||
#nx-modal *{box-sizing:border-box}
|
||||
.nx-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #e5e7eb}
|
||||
.nx-header h3{margin:0;font-size:17px;font-weight:600}
|
||||
.nx-close{cursor:pointer;font-size:20px;color:#999;background:none;border:none;width:auto;height:auto;box-shadow:none}
|
||||
.nx-close:hover{color:#333;transform:none}
|
||||
.nx-tabs{display:flex;border-bottom:1px solid #e5e7eb}
|
||||
.nx-tab{flex:1;padding:10px;text-align:center;font-size:13px;font-weight:500;cursor:pointer;border:none;background:none;color:#6b7280;border-bottom:2px solid transparent;transition:all .15s}
|
||||
.nx-tab.active{color:#3b82f6;border-bottom-color:#3b82f6}
|
||||
.nx-body{flex:1;overflow-y:auto;padding:16px 20px}
|
||||
.nx-row{display:flex;gap:8px;margin-bottom:10px}
|
||||
.nx-select,.nx-input,.nx-textarea{width:100%;padding:9px 12px;border-radius:8px;border:1px solid #d1d5db;font-size:14px;font-family:inherit}
|
||||
.nx-textarea{height:90px;resize:vertical}
|
||||
.nx-textarea.drag-over{border-color:#3b82f6;background:#eff6ff}
|
||||
.nx-btn{padding:10px;border-radius:8px;border:1px solid #d1d5db;cursor:pointer;background:#f9fafb;font-size:14px;text-align:center;flex:1}
|
||||
.nx-btn:hover{background:#f3f4f6}
|
||||
.nx-btn-primary{background:#3b82f6;color:#fff;border:none;font-weight:600;width:100%;padding:12px;font-size:15px}
|
||||
.nx-btn-primary:hover{background:#2563eb}
|
||||
.nx-btn-primary:disabled{opacity:.5;cursor:not-allowed}
|
||||
.nx-att-list{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
|
||||
.nx-att-item{position:relative;width:60px;height:60px;border-radius:6px;overflow:hidden;border:1px solid #e5e7eb}
|
||||
.nx-att-item img{width:100%;height:100%;object-fit:cover}
|
||||
.nx-att-remove{position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#ef4444;color:#fff;font-size:11px;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
|
||||
.nx-ctx{background:#f8fafc;border-radius:8px;padding:8px 12px;margin-bottom:10px;font-size:12px;color:#6b7280}
|
||||
.nx-result{margin-top:12px;padding:12px;border-radius:8px;font-size:14px}
|
||||
.nx-result.ok{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534}
|
||||
.nx-result.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||||
.nx-ticket{padding:12px;border:1px solid #e5e7eb;border-radius:10px;margin-bottom:8px;cursor:pointer;transition:background .1s}
|
||||
.nx-ticket:hover{background:#f9fafb}
|
||||
.nx-ticket-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
|
||||
.nx-ticket-id{font-size:12px;font-weight:600;color:#3b82f6}
|
||||
.nx-ticket-status{font-size:11px;padding:2px 8px;border-radius:10px;font-weight:500}
|
||||
.nx-ticket-subject{font-size:14px;font-weight:500;margin-bottom:4px}
|
||||
.nx-ticket-meta{font-size:12px;color:#9ca3af}
|
||||
.nx-ticket-ai{font-size:13px;color:#7c3aed;background:#f5f3ff;padding:8px;border-radius:6px;margin-top:6px}
|
||||
.nx-notif{padding:10px 12px;border-bottom:1px solid #f3f4f6;cursor:pointer;transition:background .1s}
|
||||
.nx-notif:hover{background:#f9fafb}
|
||||
.nx-notif.unread{background:#eff6ff}
|
||||
.nx-notif-title{font-size:14px;font-weight:500;margin-bottom:2px}
|
||||
.nx-notif-body{font-size:13px;color:#6b7280}
|
||||
.nx-notif-meta{font-size:11px;color:#9ca3af;margin-top:4px}
|
||||
.nx-notif-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
.nx-notif-dot.BUG_FIX{background:#22c55e}.nx-notif-dot.UPDATE{background:#3b82f6}
|
||||
.nx-notif-dot.ALERT{background:#ef4444}.nx-notif-dot.INFO{background:#9ca3af}
|
||||
.nx-empty{text-align:center;padding:30px;color:#9ca3af;font-size:14px}
|
||||
.nx-chat-area{flex:1;overflow-y:auto;padding:12px;min-height:200px;max-height:50vh;display:flex;flex-direction:column;gap:8px}
|
||||
.nx-chat-msg{max-width:85%;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5;word-wrap:break-word;animation:nxFadeIn .2s}
|
||||
.nx-chat-msg.user{align-self:flex-end;background:#4f46e5;color:#fff;border-bottom-right-radius:4px}
|
||||
.nx-chat-msg.ai{align-self:flex-start;background:#f3f4f6;color:#1f2937;border-bottom-left-radius:4px}
|
||||
.nx-chat-msg.system{align-self:center;background:#fef3c7;color:#92400e;font-size:13px;border-radius:8px;text-align:center}
|
||||
.nx-chat-input{display:flex;gap:8px;padding:12px;border-top:1px solid #e5e7eb}
|
||||
.nx-chat-input input{flex:1;padding:10px 14px;border:1px solid #d1d5db;border-radius:20px;font-size:14px;font-family:inherit;outline:none}
|
||||
.nx-chat-input input:focus{border-color:#4f46e5}
|
||||
.nx-chat-input button{width:40px;height:40px;border-radius:50%;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center}
|
||||
.nx-chat-input button:disabled{opacity:.4;cursor:not-allowed}
|
||||
.nx-chat-actions{display:flex;gap:8px;padding:0 12px 12px;justify-content:center}
|
||||
.nx-chat-actions button{padding:8px 16px;border-radius:8px;border:1px solid #d1d5db;background:#f9fafb;font-size:13px;cursor:pointer;font-family:inherit}
|
||||
.nx-chat-actions button:hover{background:#f3f4f6}
|
||||
.nx-chat-actions .resolve{border-color:#22c55e;color:#16a34a}
|
||||
.nx-chat-actions .classic{border-color:#3b82f6;color:#2563eb}
|
||||
.nx-typing{display:flex;gap:4px;padding:6px 14px;align-self:flex-start}
|
||||
.nx-typing span{width:6px;height:6px;border-radius:50%;background:#9ca3af;animation:nxBounce .6s infinite alternate}
|
||||
.nx-typing span:nth-child(2){animation-delay:.15s}
|
||||
.nx-typing span:nth-child(3){animation-delay:.3s}
|
||||
@keyframes nxFadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes nxBounce{to{transform:translateY(-4px);opacity:.4}}
|
||||
.nx-status-OPEN{background:#dbeafe;color:#1d4ed8}
|
||||
.nx-status-IN_PROGRESS{background:#fef3c7;color:#92400e}
|
||||
.nx-status-PENDING{background:#f3f4f6;color:#4b5563}
|
||||
.nx-status-RESOLVED{background:#d1fae5;color:#065f46}
|
||||
.nx-status-CLOSED{background:#e5e7eb;color:#6b7280}
|
||||
`;
|
||||
|
||||
// ==================== INIT ====================
|
||||
function init() {
|
||||
// Inject styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = STYLES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Bottom bar: 🛟 supporto + 🔔 notifiche
|
||||
// Standard suite Agile: bug=fa-life-ring "Chiedi supporto" / bell=fa-bell
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'nx-bar';
|
||||
bar.innerHTML = `
|
||||
<button id="nx-bell-btn" title="Notifiche"><i class="fas fa-bell"></i><span id="nx-bell-badge" style="display:none">0</span></button>
|
||||
<button id="nx-bug-btn" title="Chiedi supporto"><i class="fas fa-life-ring"></i></button>
|
||||
`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
document.getElementById('nx-bug-btn').onclick = () => openModal('new');
|
||||
document.getElementById('nx-bell-btn').onclick = () => openModal('notif');
|
||||
|
||||
// Hook campanella topbar del prodotto (se esiste)
|
||||
const topBell = document.getElementById('btn-notifications');
|
||||
if (topBell) {
|
||||
// Rimuovi il vecchio dropdown notifiche del prodotto (evita doppia finestra)
|
||||
const oldDropdown = document.getElementById('notif-dropdown');
|
||||
if (oldDropdown) oldDropdown.remove();
|
||||
// Clona per rimuovere vecchi listener
|
||||
const newBell = topBell.cloneNode(true);
|
||||
topBell.parentNode.replaceChild(newBell, topBell);
|
||||
newBell.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); openModal('notif'); });
|
||||
window._nxTopBadge = function(c) {
|
||||
const b = newBell.querySelector('.badge') || document.getElementById('worklist-badge');
|
||||
if (b) { b.textContent = c > 99 ? '99+' : c; b.style.display = c > 0 ? '' : 'none'; }
|
||||
};
|
||||
}
|
||||
|
||||
// Start notification polling
|
||||
pollNotifications();
|
||||
_notifPollTimer = setInterval(pollNotifications, 60000);
|
||||
}
|
||||
|
||||
// ==================== NOTIFICATION POLLING ====================
|
||||
async function pollNotifications() {
|
||||
try {
|
||||
const res = await fetch(`${API}/notifications/unread-count?product=${CFG.product}`, {
|
||||
headers: _headers()
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
_notifCount = d.data.unread;
|
||||
const badge = document.getElementById('nx-bell-badge');
|
||||
if (badge) {
|
||||
badge.style.display = _notifCount > 0 ? 'flex' : 'none';
|
||||
badge.textContent = _notifCount > 9 ? '9+' : String(_notifCount);
|
||||
}
|
||||
// Aggiorna anche campanella topbar del prodotto (se hookata)
|
||||
if (window._nxTopBadge) window._nxTopBadge(_notifCount);
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ==================== MODAL ====================
|
||||
function openModal(tab) {
|
||||
closeModal();
|
||||
_attachments = [];
|
||||
_chatMode = false; // AI chat e nel widget separato ai-assistant.js
|
||||
_supportSessionId = null;
|
||||
_chatMessages = [];
|
||||
// Nasconde FAB ARIA quando il modale segnalazioni e aperto
|
||||
var aiaFab = document.getElementById('ai-chat-fab');
|
||||
if (aiaFab) aiaFab.style.display = 'none';
|
||||
|
||||
const bg = document.createElement('div');
|
||||
bg.id = 'nx-modal-bg';
|
||||
bg.onclick = (e) => { if (e.target === bg) closeModal(); };
|
||||
|
||||
bg.innerHTML = `
|
||||
<div id="nx-modal">
|
||||
<div class="nx-header">
|
||||
<h3 id="nx-title">Segnalazioni</h3>
|
||||
<button class="nx-close" onclick="document.getElementById('nx-modal-bg')?.remove();var f=document.getElementById('ai-chat-fab');if(f)f.style.display='flex'">✕</button>
|
||||
</div>
|
||||
<div class="nx-tabs">
|
||||
<button class="nx-tab" data-tab="new">Nuova</button>
|
||||
<button class="nx-tab" data-tab="mine">Le Mie</button>
|
||||
<button class="nx-tab" data-tab="done">Risolte</button>
|
||||
<button class="nx-tab" data-tab="notif">Notifiche</button>
|
||||
</div>
|
||||
<div class="nx-body" id="nx-body"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(bg);
|
||||
|
||||
// Tab click
|
||||
bg.querySelectorAll('.nx-tab').forEach(t => {
|
||||
t.onclick = () => switchTab(t.dataset.tab);
|
||||
});
|
||||
|
||||
switchTab(tab || 'new');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('nx-modal-bg')?.remove();
|
||||
// Rimostra FAB ARIA quando il modale segnalazioni si chiude
|
||||
var aiaFab = document.getElementById('ai-chat-fab');
|
||||
if (aiaFab) aiaFab.style.display = 'flex';
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.nx-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === tab);
|
||||
});
|
||||
const body = document.getElementById('nx-body');
|
||||
if (!body) return;
|
||||
|
||||
if (tab === 'new') {
|
||||
if (_chatMode && !_supportSessionId) renderChatMode(body);
|
||||
else if (_chatMode && _supportSessionId) renderChatUI(body);
|
||||
else renderNewForm(body);
|
||||
}
|
||||
else if (tab === 'mine') renderMyTickets(body, 'ACTIVE');
|
||||
else if (tab === 'done') renderMyTickets(body, 'DONE');
|
||||
else if (tab === 'notif') renderNotifications(body);
|
||||
}
|
||||
|
||||
// ==================== TAB: CHAT MODE (Pre-Ticket AI Dialog) ====================
|
||||
|
||||
async function renderChatMode(body) {
|
||||
_chatMessages = [];
|
||||
_supportSessionId = null;
|
||||
body.innerHTML = '<div style="text-align:center;padding:30px;color:#9ca3af">Avvio assistente...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/support-sessions`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ product: CFG.product, callerEmail: CFG.userEmail, callerName: CFG.userName })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
_supportSessionId = d.data.sessionId;
|
||||
_chatMessages.push({ role: 'ai', text: d.data.welcomeMessage });
|
||||
} else {
|
||||
_chatMessages.push({ role: 'ai', text: 'Ciao! Descrivi il problema e ti aiuto a risolverlo.' });
|
||||
}
|
||||
} catch(e) {
|
||||
_chatMessages.push({ role: 'ai', text: 'Ciao! Descrivi il problema e ti aiuto a risolverlo.' });
|
||||
}
|
||||
|
||||
renderChatUI(body);
|
||||
}
|
||||
|
||||
function renderChatUI(body) {
|
||||
body.style.padding = '0';
|
||||
body.innerHTML = `
|
||||
<div class="nx-chat-area" id="nx-chat-area"></div>
|
||||
<div class="nx-chat-actions">
|
||||
<button class="resolve" onclick="window._nxChatResolve()">✅ Risolto, grazie!</button>
|
||||
<button class="classic" onclick="window._nxChatSkip()">📝 Segnalazione classica</button>
|
||||
</div>
|
||||
<div class="nx-chat-input">
|
||||
<input type="text" id="nx-chat-input" placeholder="Scrivi il tuo problema..." autofocus>
|
||||
<button id="nx-chat-send" onclick="window._nxChatSend()">➤</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Render messages
|
||||
const area = document.getElementById('nx-chat-area');
|
||||
_chatMessages.forEach(m => appendChatBubble(area, m.role, m.text));
|
||||
area.scrollTop = area.scrollHeight;
|
||||
|
||||
// Enter to send
|
||||
document.getElementById('nx-chat-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !_chatLoading) window._nxChatSend();
|
||||
});
|
||||
}
|
||||
|
||||
function appendChatBubble(area, role, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `nx-chat-msg ${role}`;
|
||||
div.textContent = text;
|
||||
area.appendChild(div);
|
||||
}
|
||||
|
||||
function showTyping(area) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'nx-typing';
|
||||
div.id = 'nx-typing';
|
||||
div.innerHTML = '<span></span><span></span><span></span>';
|
||||
area.appendChild(div);
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
|
||||
function hideTyping() {
|
||||
document.getElementById('nx-typing')?.remove();
|
||||
}
|
||||
|
||||
window._nxChatSend = async () => {
|
||||
const input = document.getElementById('nx-chat-input');
|
||||
const text = input.value.trim();
|
||||
if (!text || _chatLoading) return;
|
||||
|
||||
input.value = '';
|
||||
_chatLoading = true;
|
||||
|
||||
const area = document.getElementById('nx-chat-area');
|
||||
_chatMessages.push({ role: 'user', text });
|
||||
appendChatBubble(area, 'user', text);
|
||||
area.scrollTop = area.scrollHeight;
|
||||
|
||||
const sendBtn = document.getElementById('nx-chat-send');
|
||||
sendBtn.disabled = true;
|
||||
showTyping(area);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/support-sessions/${_supportSessionId}/message`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ content: text })
|
||||
});
|
||||
const d = await res.json();
|
||||
hideTyping();
|
||||
|
||||
if (d.success) {
|
||||
const reply = d.data.reply;
|
||||
_chatMessages.push({ role: 'ai', text: reply });
|
||||
appendChatBubble(area, 'ai', reply);
|
||||
|
||||
// Check for escalation/forward action from AI tool calls
|
||||
if (d.data.action) {
|
||||
if (d.data.action.type === 'ESCALATED') {
|
||||
const ticketId = d.data.action.data?.ticketId;
|
||||
_chatMessages.push({ role: 'system', text: `Ticket #${ticketId} creato. Il team lo prendera in carico.` });
|
||||
appendChatBubble(area, 'system', `Ticket #${ticketId} creato. Il team lo prendera in carico.`);
|
||||
}
|
||||
if (d.data.action.type === 'FORWARDED') {
|
||||
const expertName = d.data.action.data?.expertName || 'esperto';
|
||||
_chatMessages.push({ role: 'system', text: `Domanda inoltrata a ${expertName}. Riceverai una risposta via email.` });
|
||||
appendChatBubble(area, 'system', `Domanda inoltrata a ${expertName}. Riceverai una risposta via email.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_chatMessages.push({ role: 'ai', text: 'Mi dispiace, non sono riuscito a elaborare la risposta. Puoi riprovare?' });
|
||||
appendChatBubble(area, 'ai', 'Mi dispiace, non sono riuscito a elaborare la risposta. Puoi riprovare?');
|
||||
}
|
||||
} catch(e) {
|
||||
hideTyping();
|
||||
_chatMessages.push({ role: 'ai', text: 'Connessione non disponibile. Prova la segnalazione classica.' });
|
||||
appendChatBubble(area, 'ai', 'Connessione non disponibile. Prova la segnalazione classica.');
|
||||
}
|
||||
|
||||
area.scrollTop = area.scrollHeight;
|
||||
_chatLoading = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
};
|
||||
|
||||
window._nxChatResolve = async () => {
|
||||
if (_supportSessionId) {
|
||||
try {
|
||||
await fetch(`${API}/support-sessions/${_supportSessionId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ rating: 5 })
|
||||
});
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
const area = document.getElementById('nx-chat-area');
|
||||
if (area) {
|
||||
appendChatBubble(area, 'system', 'Grazie! Se hai altre domande, siamo qui. 😊');
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => { _supportSessionId = null; _chatMessages = []; _chatMode = true; closeModal(); }, 2000);
|
||||
};
|
||||
|
||||
window._nxChatSkip = () => {
|
||||
_chatMode = false;
|
||||
const body = document.getElementById('nx-body');
|
||||
if (body) {
|
||||
body.style.padding = '16px 20px';
|
||||
renderNewForm(body);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== TAB: NUOVA SEGNALAZIONE ====================
|
||||
function renderNewForm(body) {
|
||||
const ctx = getContext();
|
||||
body.innerHTML = `
|
||||
<div class="nx-ctx">📍 ${ctx.pageUrl.split('#')[1] || ctx.pageUrl.split('/').pop()} ${ctx.companyName ? '| 🏢 ' + ctx.companyName : ''}</div>
|
||||
<div class="nx-row">
|
||||
<select class="nx-select" id="nx-tipo" style="flex:1">
|
||||
<option value="COMPLAINT">🐛 Bug / Errore</option>
|
||||
<option value="SUPPORT_REQUEST">🎨 Interfaccia</option>
|
||||
<option value="INFO_REQUEST">💡 Suggerimento</option>
|
||||
<option value="OTHER">❓ Domanda</option>
|
||||
</select>
|
||||
<select class="nx-select" id="nx-prio" style="flex:1">
|
||||
<option value="MEDIUM">Media</option>
|
||||
<option value="LOW">Bassa</option>
|
||||
<option value="HIGH">Alta</option>
|
||||
<option value="URGENT">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="nx-textarea" id="nx-desc" placeholder="Descrivi il problema... (puoi incollare screenshot con Ctrl+V)"></textarea>
|
||||
<div class="nx-att-list" id="nx-att-list"></div>
|
||||
<div class="nx-row">
|
||||
<button class="nx-btn" id="nx-voice-btn" onclick="window._nxVoice()">🎤 Parla</button>
|
||||
<button class="nx-btn" id="nx-screenshot-btn" onclick="window._nxScreenshot()">📸 Screenshot</button>
|
||||
<label class="nx-btn" style="cursor:pointer">
|
||||
📎 Allega
|
||||
<input type="file" accept="image/*,.pdf" multiple style="display:none" onchange="window._nxAttachFiles(this.files)">
|
||||
</label>
|
||||
</div>
|
||||
<button class="nx-btn nx-btn-primary" id="nx-submit" onclick="window._nxSubmit()">Invia Segnalazione</button>
|
||||
<div id="nx-result" style="display:none"></div>
|
||||
`;
|
||||
|
||||
// Paste handler
|
||||
const desc = document.getElementById('nx-desc');
|
||||
desc.addEventListener('paste', handlePaste);
|
||||
// Drag & drop
|
||||
desc.addEventListener('dragover', (e) => { e.preventDefault(); desc.classList.add('drag-over'); });
|
||||
desc.addEventListener('dragleave', () => desc.classList.remove('drag-over'));
|
||||
desc.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
desc.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== PASTE / SCREENSHOT / ATTACH ====================
|
||||
function handlePaste(e) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
blobToAttachment(blob, `screenshot_${Date.now()}.png`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blobToAttachment(blob, filename) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
_attachments.push({
|
||||
filename: filename,
|
||||
mimeType: blob.type,
|
||||
size: blob.size,
|
||||
data: reader.result.split(',')[1], // base64 without prefix
|
||||
preview: reader.result
|
||||
});
|
||||
renderAttachments();
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
function addFiles(files) {
|
||||
for (const file of files) {
|
||||
if (_attachments.length >= 10) break;
|
||||
blobToAttachment(file, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
window._nxAttachFiles = (files) => addFiles(files);
|
||||
|
||||
window._nxScreenshot = async () => {
|
||||
const btn = document.getElementById('nx-screenshot-btn');
|
||||
try {
|
||||
// Hide modal temporarily
|
||||
const modal = document.getElementById('nx-modal-bg');
|
||||
if (modal) modal.style.display = 'none';
|
||||
|
||||
btn.textContent = '⏳ Cattura...';
|
||||
|
||||
// Wait a frame for modal to hide
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
// Use html2canvas if available, otherwise use Screen Capture API
|
||||
if (typeof html2canvas !== 'undefined') {
|
||||
const canvas = await html2canvas(document.body, { useCORS: true, scale: 1 });
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) blobToAttachment(blob, `screenshot_${Date.now()}.png`);
|
||||
if (modal) modal.style.display = 'flex';
|
||||
btn.textContent = '📸 Screenshot';
|
||||
}, 'image/png');
|
||||
} else if (navigator.mediaDevices?.getDisplayMedia) {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { mediaSource: 'screen' } });
|
||||
const track = stream.getVideoTracks()[0];
|
||||
const imageCapture = new ImageCapture(track);
|
||||
const bitmap = await imageCapture.grabFrame();
|
||||
track.stop();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) blobToAttachment(blob, `screenshot_${Date.now()}.png`);
|
||||
if (modal) modal.style.display = 'flex';
|
||||
btn.textContent = '📸 Screenshot';
|
||||
}, 'image/png');
|
||||
} else {
|
||||
// Fallback: canvas from body (basic)
|
||||
if (modal) modal.style.display = 'flex';
|
||||
btn.textContent = '📸 Screenshot';
|
||||
alert('Screenshot non supportato in questo browser. Usa Ctrl+V per incollare.');
|
||||
}
|
||||
} catch(err) {
|
||||
const modal = document.getElementById('nx-modal-bg');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
btn.textContent = '📸 Screenshot';
|
||||
if (err.name !== 'NotAllowedError') {
|
||||
console.warn('Screenshot error:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function renderAttachments() {
|
||||
const list = document.getElementById('nx-att-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _attachments.map((a, i) => `
|
||||
<div class="nx-att-item">
|
||||
<img src="${a.preview}" alt="${a.filename}">
|
||||
<button class="nx-att-remove" onclick="window._nxRemoveAtt(${i})">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
window._nxRemoveAtt = (i) => { _attachments.splice(i, 1); renderAttachments(); };
|
||||
|
||||
// ==================== VOICE ====================
|
||||
window._nxVoice = () => {
|
||||
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||
alert('Riconoscimento vocale non supportato. Usa Chrome.'); return;
|
||||
}
|
||||
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
|
||||
recognition.lang = CFG.lang === 'it' ? 'it-IT' : CFG.lang + '-' + CFG.lang.toUpperCase();
|
||||
recognition.continuous = false;
|
||||
const btn = document.getElementById('nx-voice-btn');
|
||||
btn.textContent = '🔴 Parla ora...';
|
||||
btn.style.background = '#fee2e2';
|
||||
recognition.onresult = (e) => {
|
||||
const desc = document.getElementById('nx-desc');
|
||||
desc.value += (desc.value ? '\n' : '') + e.results[0][0].transcript;
|
||||
btn.textContent = '🎤 Parla'; btn.style.background = '';
|
||||
};
|
||||
recognition.onerror = recognition.onend = () => { btn.textContent = '🎤 Parla'; btn.style.background = ''; };
|
||||
recognition.start();
|
||||
};
|
||||
|
||||
// ==================== SUBMIT ====================
|
||||
window._nxSubmit = async () => {
|
||||
const desc = document.getElementById('nx-desc')?.value?.trim();
|
||||
const tipo = document.getElementById('nx-tipo')?.value;
|
||||
const prio = document.getElementById('nx-prio')?.value;
|
||||
const submitBtn = document.getElementById('nx-submit');
|
||||
const result = document.getElementById('nx-result');
|
||||
|
||||
if (!desc && _attachments.length === 0) { alert('Descrivi il problema o allega uno screenshot'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Invio in corso...';
|
||||
|
||||
const ctx = getContext();
|
||||
const versionTag = `[v${WIDGET_VERSION} build ${WIDGET_BUILD}]`;
|
||||
const payload = {
|
||||
transcription: desc ? `${desc}\n\n${versionTag}` : `Segnalazione con screenshot ${versionTag}`,
|
||||
callerName: CFG.userName || ctx.userName,
|
||||
callerEmail: CFG.userEmail || ctx.userEmail,
|
||||
kind: tipo,
|
||||
priority: prio,
|
||||
queue: 'SUPPORT',
|
||||
product: CFG.product,
|
||||
pageUrl: ctx.pageUrl,
|
||||
reporterRole: CFG.userRole || ctx.userRole,
|
||||
lang: CFG.lang,
|
||||
appVersion: WIDGET_VERSION,
|
||||
appBuild: WIDGET_BUILD
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Create ticket
|
||||
const res = await fetch(`${API}/tickets/voice-report`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const ticketId = data.data.ticket.id;
|
||||
|
||||
// 2. Attach files
|
||||
if (_attachments.length > 0) {
|
||||
await fetch(`${API}/tickets/${ticketId}/attachments-batch`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ attachments: _attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, size: a.size, data: a.data })) })
|
||||
});
|
||||
}
|
||||
|
||||
result.className = 'nx-result ok';
|
||||
result.style.display = 'block';
|
||||
result.innerHTML = `✅ Segnalazione #${ticketId} inviata!<br><small>${data.data.ticket.aiResponse || 'Grazie, il team la prendera in carico.'}</small>`;
|
||||
submitBtn.textContent = '✓ Inviato';
|
||||
|
||||
// Play audio if available
|
||||
if (data.data.voice?.audioBase64) {
|
||||
try { new Audio(data.data.voice.audioBase64).play(); } catch(e) {}
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Errore');
|
||||
}
|
||||
} catch(err) {
|
||||
result.className = 'nx-result err';
|
||||
result.style.display = 'block';
|
||||
result.textContent = '❌ ' + (err.message || 'Servizio non raggiungibile');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Invia Segnalazione';
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== TAB: MIE SEGNALAZIONI / RISOLTE ====================
|
||||
async function renderMyTickets(body, statusFilter) {
|
||||
body.innerHTML = '<div class="nx-empty">Caricamento...</div>';
|
||||
const email = CFG.userEmail;
|
||||
if (!email) { body.innerHTML = '<div class="nx-empty">Email utente non configurata</div>'; return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/tickets/my?callerEmail=${encodeURIComponent(email)}&status=${statusFilter}&product=${CFG.product}`, {
|
||||
headers: _headers()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.data.tickets.length) {
|
||||
body.innerHTML = `<div class="nx-empty">${statusFilter === 'DONE' ? 'Nessuna segnalazione risolta' : 'Nessuna segnalazione aperta'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = data.data.tickets.map(t => `
|
||||
<div class="nx-ticket" onclick="window._nxToggleTicket(this)">
|
||||
<div class="nx-ticket-head">
|
||||
<span class="nx-ticket-id">#${t.id}</span>
|
||||
<span class="nx-ticket-status nx-status-${t.status}">${t.status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div class="nx-ticket-subject">${t.subject || t.operativeSummary?.substring(0, 80) || 'Senza oggetto'}</div>
|
||||
<div class="nx-ticket-meta">${t.priority} | ${timeAgo(t.createdAt)}</div>
|
||||
${t.aiResponse ? `<div class="nx-ticket-ai" style="display:none">💬 ${t.aiResponse}</div>` : ''}
|
||||
${t.messages?.length ? `<div class="nx-ticket-ai" style="display:none">📝 ${t.messages.map(m => `<b>${m.role}</b>: ${m.content}`).join('<br>')}</div>` : ''}
|
||||
${t.status === 'RESOLVED' ? `<button class="nx-reopen-btn" onclick="event.stopPropagation(); window._nxReopenTicket(${t.id}, this)" style="margin-top:8px;width:100%;padding:8px;border:1px solid #ef4444;border-radius:8px;background:#fef2f2;color:#dc2626;font-size:13px;font-weight:600;cursor:pointer">Non risolto — Riapri</button>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
} catch(err) {
|
||||
body.innerHTML = '<div class="nx-empty">Servizio non raggiungibile</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window._nxToggleTicket = (el) => {
|
||||
el.querySelectorAll('.nx-ticket-ai').forEach(d => {
|
||||
d.style.display = d.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
window._nxReopenTicket = async (ticketId, btn) => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Riapertura...';
|
||||
try {
|
||||
const res = await fetch(`${API}/tickets/${ticketId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ status: 'OPEN', performedBy: CFG.userEmail || 'utente' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.textContent = 'Riaperto!';
|
||||
btn.style.background = '#d1fae5';
|
||||
btn.style.color = '#065f46';
|
||||
btn.style.borderColor = '#10b981';
|
||||
// Aggiunge un messaggio al ticket con la segnalazione dell'utente
|
||||
await fetch(`${API}/tickets/${ticketId}/message`, {
|
||||
method: 'POST',
|
||||
headers: _headers(true),
|
||||
body: JSON.stringify({ content: 'Riaperto dall\'utente: il problema non era risolto.', role: 'USER' })
|
||||
});
|
||||
setTimeout(() => renderTickets(btn.closest('.nx-body') || document.querySelector('.nx-body')), 1000);
|
||||
} else {
|
||||
btn.textContent = data.error?.message || 'Errore';
|
||||
btn.style.background = '#fee2e2';
|
||||
}
|
||||
} catch(e) {
|
||||
btn.textContent = 'Errore connessione';
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== TAB: NOTIFICHE ====================
|
||||
async function renderNotifications(body) {
|
||||
body.innerHTML = '<div class="nx-empty">Caricamento...</div>';
|
||||
try {
|
||||
const res = await fetch(`${API}/notifications?product=${CFG.product}&limit=30`, {
|
||||
headers: _headers()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.data.notifications.length) {
|
||||
body.innerHTML = '<div class="nx-empty">Nessuna notifica</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div style="text-align:right;margin-bottom:8px">
|
||||
<button class="nx-btn" style="font-size:12px;padding:4px 10px" onclick="window._nxReadAll()">Segna tutte lette</button>
|
||||
</div>`;
|
||||
|
||||
html += data.data.notifications.map(n => `
|
||||
<div class="nx-notif ${n.readAt ? '' : 'unread'}" onclick="window._nxReadNotif(${n.id}, this)">
|
||||
<div class="nx-notif-title"><span class="nx-notif-dot ${n.type}"></span>${n.title}</div>
|
||||
${n.body ? `<div class="nx-notif-body">${n.body}</div>` : ''}
|
||||
<div class="nx-notif-meta">${n.type} | ${timeAgo(n.createdAt)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
body.innerHTML = html;
|
||||
} catch(err) {
|
||||
body.innerHTML = '<div class="nx-empty">Servizio non raggiungibile</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window._nxReadNotif = async (id, el) => {
|
||||
try {
|
||||
await fetch(`${API}/notifications/${id}/read`, {
|
||||
method: 'PUT', headers: _headers()
|
||||
});
|
||||
el.classList.remove('unread');
|
||||
pollNotifications();
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
window._nxReadAll = async () => {
|
||||
try {
|
||||
await fetch(`${API}/notifications/read-all?product=${CFG.product}`, {
|
||||
method: 'PUT', headers: _headers()
|
||||
});
|
||||
document.querySelectorAll('.nx-notif.unread').forEach(el => el.classList.remove('unread'));
|
||||
pollNotifications();
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
// ==================== UTILS ====================
|
||||
function getContext() {
|
||||
return {
|
||||
pageUrl: window.location.href,
|
||||
userName: CFG.userName || (typeof api !== 'undefined' && api.user?.name) || '',
|
||||
userEmail: CFG.userEmail || (typeof api !== 'undefined' && api.user?.email) || '',
|
||||
userRole: CFG.userRole || (typeof api !== 'undefined' && api.user?.role) || '',
|
||||
companyName: (typeof api !== 'undefined' && api.companies?.[0]?.name) || ''
|
||||
};
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
|
||||
if (diff < 60) return 'ora';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' min fa';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + ' ore fa';
|
||||
return Math.floor(diff / 86400) + ' giorni fa';
|
||||
}
|
||||
|
||||
// ==================== MAINTENANCE CHECK (polling maintenance.json) ====================
|
||||
var _maintOverlay = null;
|
||||
function checkMaintenance() {
|
||||
fetch('/maintenance.json?_=' + Date.now()).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d && d.active === true) {
|
||||
if (!_maintOverlay) {
|
||||
_maintOverlay = document.createElement('div');
|
||||
_maintOverlay.id = 'agile-maint-overlay';
|
||||
_maintOverlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(10,11,15,0.95);display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
|
||||
_maintOverlay.innerHTML = '<div style="text-align:center;max-width:400px;padding:32px">'
|
||||
+ '<div style="font-size:48px;margin-bottom:16px">🔧</div>'
|
||||
+ '<h2 style="color:#fff;font-size:22px;font-weight:700;margin:0 0 12px">Aggiornamento in corso</h2>'
|
||||
+ '<p style="color:#9ca3af;font-size:15px;line-height:1.6;margin:0 0 20px">Stiamo applicando un miglioramento al sistema. Torneremo operativi tra pochi istanti.</p>'
|
||||
+ '<div style="width:48px;height:48px;border:3px solid rgba(124,58,237,0.3);border-top-color:#7c3aed;border-radius:50%;margin:0 auto;animation:agile-spin 1s linear infinite"></div>'
|
||||
+ '<style>@keyframes agile-spin{to{transform:rotate(360deg)}}</style>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(_maintOverlay);
|
||||
}
|
||||
} else {
|
||||
if (_maintOverlay) { _maintOverlay.remove(); _maintOverlay = null; }
|
||||
}
|
||||
}).catch(function() {
|
||||
if (_maintOverlay) { _maintOverlay.remove(); _maintOverlay = null; }
|
||||
});
|
||||
}
|
||||
setInterval(checkMaintenance, 10000);
|
||||
setTimeout(checkMaintenance, 3000);
|
||||
|
||||
// ==================== START ====================
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@ -214,6 +214,7 @@ function loadSidebar() {
|
||||
{
|
||||
label: 'Sistema', i18nKey: 'nav.system',
|
||||
items: [
|
||||
{ name: 'Guida all\'uso', href: 'guida.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>` },
|
||||
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' },
|
||||
{ name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() },
|
||||
{ name: 'Simulazione Demo', href: 'simulate.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>` },
|
||||
|
||||
@ -904,6 +904,22 @@ const HelpSystem = (function () {
|
||||
|
||||
// ── Rendering del contenuto HTML di help ─────────────────────────
|
||||
|
||||
// Mapping pageId -> ancora capitolo nella Guida completa (guida.html)
|
||||
var _guideAnchor = {
|
||||
'dashboard': 'cap-4',
|
||||
'assessment': 'cap-5',
|
||||
'risks': 'cap-6',
|
||||
'incidents': 'cap-7',
|
||||
'policies': 'cap-8',
|
||||
'supply-chain': 'cap-9',
|
||||
'training': 'cap-10',
|
||||
'assets': 'cap-11',
|
||||
'reports': 'cap-12',
|
||||
'feedback': 'cap-13',
|
||||
'workflow': 'cap-4',
|
||||
'settings': 'cap-3'
|
||||
};
|
||||
|
||||
function _renderHelpContent(pageId) {
|
||||
var data = _helpContent[pageId];
|
||||
if (!data) return null;
|
||||
@ -936,6 +952,15 @@ const HelpSystem = (function () {
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Link alla guida completa (per utenti che vogliono approfondire)
|
||||
var anchor = _guideAnchor[pageId] || '';
|
||||
var guideHref = 'guida.html' + (anchor ? '#' + anchor : '');
|
||||
html += '<div style="margin-top:20px;padding:14px 18px;background:#eff6ff;border-left:4px solid #1e40af;border-radius:6px;">';
|
||||
html += '<strong style="color:#1e40af;">Sei nuovo?</strong> ';
|
||||
html += 'Apri la <a href="' + guideHref + '" style="color:#1e40af;font-weight:600;">Guida all\'uso completa</a> ';
|
||||
html += 'con spiegazioni in parole semplici, esempi pratici e cosa dice la norma.';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return { title: data.title, content: html };
|
||||
|
||||
189
public/mobile-conversion.css
Normal file
189
public/mobile-conversion.css
Normal file
@ -0,0 +1,189 @@
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
* Mobile Conversion Layer — Agile Technology suite
|
||||
* Riutilizzabile su EViX, QSA, NIS2, TRPG, LG231, ecc.
|
||||
* Si attiva SOLO su mobile (≤768px) — desktop invariato.
|
||||
* Classi prefissate .mobconv-* per zero conflitti con CSS host.
|
||||
* ════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Variabili di tema (override via inline style sul container) ── */
|
||||
:root {
|
||||
--mobconv-accent: #E31E24;
|
||||
--mobconv-accent2: #B71518;
|
||||
--mobconv-bg: #0A0A0A;
|
||||
--mobconv-text: #FFFFFF;
|
||||
--mobconv-sub: rgba(255,255,255,0.75);
|
||||
--mobconv-border: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
/* ── Sticky CTA bar (bottom) — visibile solo mobile ────────────── */
|
||||
.mobconv-sticky {
|
||||
display: none; /* nascosta di default */
|
||||
position: fixed;
|
||||
left: 0; right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9990;
|
||||
background: linear-gradient(180deg, rgba(10,10,10,0.96), rgba(10,10,10,1));
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-top: 1px solid var(--mobconv-border);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -8px 32px rgba(0,0,0,0.45);
|
||||
transform: translateY(100%);
|
||||
transition: transform .28s ease;
|
||||
}
|
||||
.mobconv-sticky-row { max-width: 540px; margin: 0 auto; }
|
||||
.mobconv-cta { max-width: 100%; }
|
||||
/* iPhone XS / SE: padding ridotto per non sovrastare */
|
||||
@media (max-width: 380px) {
|
||||
.mobconv-sticky { padding: 8px 12px; padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
|
||||
.mobconv-cta { font-size: 14px !important; padding: 0 12px !important; min-height: 44px !important; }
|
||||
.mobconv-pill { min-height: 44px !important; min-width: 44px !important; }
|
||||
}
|
||||
.mobconv-sticky.show { transform: translateY(0); }
|
||||
|
||||
.mobconv-sticky-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobconv-pill {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--mobconv-text);
|
||||
border: 1px solid var(--mobconv-border);
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
transition: background .15s, transform .15s;
|
||||
}
|
||||
.mobconv-pill:active { transform: scale(0.96); background: rgba(255,255,255,0.14); }
|
||||
.mobconv-pill.wa { background: rgba(37,211,102,0.18); border-color: rgba(37,211,102,0.45); color: #25D366; }
|
||||
|
||||
.mobconv-cta {
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--mobconv-accent), var(--mobconv-accent2));
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 14.5px;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.06) inset;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.mobconv-cta:active { transform: scale(0.98); }
|
||||
|
||||
/* ── WhatsApp FAB (alternativa standalone — usabile su Diamante-like) ── */
|
||||
.mobconv-wa-fab {
|
||||
display: none;
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 9980;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #25D366;
|
||||
color: white;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
font-size: 26px;
|
||||
box-shadow: 0 8px 28px rgba(37,211,102,0.45);
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
animation: mobconv-pulse 2.4s ease-out infinite;
|
||||
}
|
||||
.mobconv-wa-fab:active { transform: scale(0.92); }
|
||||
@keyframes mobconv-pulse {
|
||||
0% { box-shadow: 0 8px 28px rgba(37,211,102,0.45), 0 0 0 0 rgba(37,211,102,0.55); }
|
||||
70% { box-shadow: 0 8px 28px rgba(37,211,102,0.45), 0 0 0 18px rgba(37,211,102,0); }
|
||||
100% { box-shadow: 0 8px 28px rgba(37,211,102,0.45), 0 0 0 0 rgba(37,211,102,0); }
|
||||
}
|
||||
|
||||
/* ── Trust ribbon (sotto hero su mobile, opzionale) ─────────────── */
|
||||
.mobconv-trust {
|
||||
display: none;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.04), rgba(0,0,0,0.02));
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 14px 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mobconv-trust::-webkit-scrollbar { display: none; }
|
||||
.mobconv-trust-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.mobconv-trust-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.7);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.mobconv-trust-item::before {
|
||||
content: "✓";
|
||||
color: var(--mobconv-accent);
|
||||
font-weight: 900;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mobconv-trust-item:not(:first-child)::after {
|
||||
content: "·";
|
||||
color: rgba(0,0,0,0.25);
|
||||
margin-left: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
/* Variante dark per siti dark-theme */
|
||||
.mobconv-trust.dark {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
||||
border-top-color: rgba(255,255,255,0.08);
|
||||
border-bottom-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
.mobconv-trust.dark .mobconv-trust-item { color: rgba(255,255,255,0.85); }
|
||||
.mobconv-trust.dark .mobconv-trust-item:not(:first-child)::after { color: rgba(255,255,255,0.2); }
|
||||
|
||||
/* ── Mobile-only: attiva tutto sotto i 768px ─────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.mobconv-sticky { display: block; }
|
||||
.mobconv-wa-fab[data-enabled="true"] { display: inline-flex; }
|
||||
/* Hide WA FAB quando sticky è visibile (ridondante) */
|
||||
.mobconv-sticky.show ~ .mobconv-wa-fab { display: none !important; }
|
||||
.mobconv-trust { display: block; }
|
||||
|
||||
/* iOS: no zoom su input/select (font-size ≥16px) */
|
||||
input, select, textarea {
|
||||
font-size: max(16px, 1rem) !important;
|
||||
}
|
||||
|
||||
/* Spazio extra in bottom della pagina per non coprire CTA esistenti */
|
||||
body.mobconv-padded {
|
||||
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Su desktop nasconde TUTTO senza margine d'errore */
|
||||
@media (min-width: 769px) {
|
||||
.mobconv-sticky, .mobconv-wa-fab, .mobconv-trust { display: none !important; }
|
||||
}
|
||||
192
public/mobile-conversion.js
Normal file
192
public/mobile-conversion.js
Normal file
@ -0,0 +1,192 @@
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
* Mobile Conversion Layer — Agile Technology suite
|
||||
* Versione 1.0 — 2026-05-18
|
||||
*
|
||||
* Si attiva solo su mobile (≤768px).
|
||||
* Configurabile via <script data-*>:
|
||||
*
|
||||
* <script src="mobile-conversion.js"
|
||||
* data-primary-text="Prenota demo"
|
||||
* data-primary-action="agilehub-open"
|
||||
* data-primary-href="#contact"
|
||||
* data-phone="+390000000000"
|
||||
* data-whatsapp="390000000000"
|
||||
* data-wa-message="Buongiorno, vorrei info su…"
|
||||
* data-show-wa-fab="false"
|
||||
* data-trust-items='["12 moduli","4 anni","AI"]'
|
||||
* data-trust-after="section.hero"
|
||||
* data-trust-theme="dark"
|
||||
* data-accent="#E31E24"
|
||||
* data-accent2="#B71518"
|
||||
* data-scroll-trigger="400"
|
||||
* defer></script>
|
||||
*
|
||||
* data-primary-action:
|
||||
* - "agilehub-open" → apre la modale AgileHubApplet (se presente)
|
||||
* - "anchor" → smooth scroll a data-primary-href
|
||||
* - "external" → location.assign(data-primary-href)
|
||||
* ════════════════════════════════════════════════════════════════ */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Config dal <script data-*> ── */
|
||||
var script = document.currentScript || (function () {
|
||||
var s = document.getElementsByTagName('script');
|
||||
for (var i = s.length - 1; i >= 0; i--) {
|
||||
if (s[i].src && s[i].src.indexOf('mobile-conversion') > -1) return s[i];
|
||||
}
|
||||
return s[s.length - 1];
|
||||
})();
|
||||
var ds = script.dataset || {};
|
||||
var CFG = {
|
||||
primaryText: ds.primaryText || 'Contattaci',
|
||||
primaryAction: ds.primaryAction || 'anchor',
|
||||
primaryHref: ds.primaryHref || '#contact',
|
||||
primaryIcon: ds.primaryIcon || '→',
|
||||
phone: ds.phone || '',
|
||||
whatsapp: ds.whatsapp || '',
|
||||
waMessage: ds.waMessage || '',
|
||||
showWaFab: ds.showWaFab === 'true',
|
||||
trustItems: ds.trustItems || null, // JSON array
|
||||
trustAfter: ds.trustAfter || null, // CSS selector
|
||||
trustTheme: ds.trustTheme || 'light', // 'light' | 'dark'
|
||||
accent: ds.accent || '#E31E24',
|
||||
accent2: ds.accent2 || '#B71518',
|
||||
scrollTrigger: parseInt(ds.scrollTrigger || '400', 10)
|
||||
};
|
||||
|
||||
/* ── Bail-out se desktop o se siamo in un iframe ── */
|
||||
if (window.innerWidth > 768) return;
|
||||
|
||||
/* ── Inject CSS variables override (se accent custom) ── */
|
||||
if (CFG.accent !== '#E31E24' || CFG.accent2 !== '#B71518') {
|
||||
var st = document.createElement('style');
|
||||
st.textContent = ':root{--mobconv-accent:'+CFG.accent+';--mobconv-accent2:'+CFG.accent2+'}';
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
|
||||
/* ── Parse trust items ── */
|
||||
var trustList = null;
|
||||
if (CFG.trustItems) {
|
||||
try { trustList = JSON.parse(CFG.trustItems); } catch (e) { trustList = null; }
|
||||
}
|
||||
|
||||
/* ── Costruisci WhatsApp URL ── */
|
||||
function waUrl() {
|
||||
if (!CFG.whatsapp) return null;
|
||||
var msg = CFG.waMessage ? '?text=' + encodeURIComponent(CFG.waMessage) : '';
|
||||
return 'https://wa.me/' + CFG.whatsapp + msg;
|
||||
}
|
||||
|
||||
/* ── Render sticky bar ── */
|
||||
function buildSticky() {
|
||||
var html = ['<div class="mobconv-sticky" id="mobconv-sticky"><div class="mobconv-sticky-row">'];
|
||||
|
||||
if (CFG.phone) {
|
||||
html.push('<a class="mobconv-pill" href="tel:' + CFG.phone.replace(/\s/g,'') + '" aria-label="Telefono">📞</a>');
|
||||
}
|
||||
var wa = waUrl();
|
||||
if (wa) {
|
||||
html.push('<a class="mobconv-pill wa" href="' + wa + '" target="_blank" rel="noopener" aria-label="WhatsApp">💬</a>');
|
||||
}
|
||||
|
||||
// Primary CTA
|
||||
var ctaAttrs = 'class="mobconv-cta" id="mobconv-primary"';
|
||||
if (CFG.primaryAction === 'agilehub-open') {
|
||||
ctaAttrs += ' href="#" data-agilehub-open';
|
||||
} else if (CFG.primaryAction === 'external') {
|
||||
ctaAttrs += ' href="' + CFG.primaryHref + '" target="_blank" rel="noopener"';
|
||||
} else {
|
||||
ctaAttrs += ' href="' + CFG.primaryHref + '"';
|
||||
}
|
||||
html.push('<a ' + ctaAttrs + '>' + CFG.primaryText + ' <span style="opacity:.85">' + CFG.primaryIcon + '</span></a>');
|
||||
|
||||
html.push('</div></div>');
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
/* ── Render WhatsApp FAB ── */
|
||||
function buildFab() {
|
||||
if (!CFG.showWaFab || !CFG.whatsapp) return '';
|
||||
return '<a class="mobconv-wa-fab" data-enabled="true" href="' + waUrl() + '" target="_blank" rel="noopener" aria-label="Scrivici su WhatsApp">' +
|
||||
'<i class="fab fa-whatsapp"></i>' +
|
||||
'</a>';
|
||||
}
|
||||
|
||||
/* ── Render trust ribbon ── */
|
||||
function buildTrust() {
|
||||
if (!trustList || !trustList.length) return null;
|
||||
var dark = CFG.trustTheme === 'dark' ? ' dark' : '';
|
||||
var items = trustList.map(function (t) {
|
||||
return '<span class="mobconv-trust-item">' + t + '</span>';
|
||||
}).join('');
|
||||
return '<div class="mobconv-trust' + dark + '"><div class="mobconv-trust-row">' + items + '</div></div>';
|
||||
}
|
||||
|
||||
/* ── Mount on DOM ready ── */
|
||||
function mount() {
|
||||
// Inject sticky bar at end of body
|
||||
var stickyHtml = buildSticky();
|
||||
var fabHtml = buildFab();
|
||||
var wrap = document.createElement('div');
|
||||
wrap.innerHTML = stickyHtml + fabHtml;
|
||||
while (wrap.firstChild) document.body.appendChild(wrap.firstChild);
|
||||
|
||||
// Inject trust ribbon after hero (or specified selector)
|
||||
var trustHtml = buildTrust();
|
||||
if (trustHtml && CFG.trustAfter) {
|
||||
var anchor = document.querySelector(CFG.trustAfter);
|
||||
if (anchor) {
|
||||
var t = document.createElement('div');
|
||||
t.innerHTML = trustHtml;
|
||||
anchor.parentNode.insertBefore(t.firstChild, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
// Padding body bottom per non coprire CTA esistenti in fondo pagina
|
||||
document.body.classList.add('mobconv-padded');
|
||||
|
||||
// Smooth scroll per anchor primary
|
||||
if (CFG.primaryAction === 'anchor') {
|
||||
var btn = document.getElementById('mobconv-primary');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
var href = btn.getAttribute('href');
|
||||
if (href && href.charAt(0) === '#') {
|
||||
var tgt = document.querySelector(href);
|
||||
if (tgt) {
|
||||
e.preventDefault();
|
||||
tgt.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// agilehub-open: il modulo agilehub-applet.js auto-binda [data-agilehub-open]
|
||||
// quindi nessun bind manuale serve qui.
|
||||
|
||||
// Scroll listener: show sticky bar dopo X px
|
||||
var stickyEl = document.getElementById('mobconv-sticky');
|
||||
var triggered = false;
|
||||
function onScroll() {
|
||||
var y = window.scrollY || document.documentElement.scrollTop;
|
||||
if (!triggered && y >= CFG.scrollTrigger) {
|
||||
stickyEl.classList.add('show');
|
||||
triggered = true;
|
||||
} else if (triggered && y < CFG.scrollTrigger - 60) {
|
||||
// Reset solo se scrolla MOLTO in alto (-60 hysteresis)
|
||||
stickyEl.classList.remove('show');
|
||||
triggered = false;
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll(); // check iniziale
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
})();
|
||||
@ -479,7 +479,7 @@
|
||||
try {
|
||||
const result = await api.listRisks(params);
|
||||
if (result.success) {
|
||||
risksData = result.data || [];
|
||||
risksData = (result.data && result.data.items) || [];
|
||||
renderRisksTable(risksData);
|
||||
} else {
|
||||
showNotification(result.message || 'Errore nel caricamento rischi', 'error');
|
||||
|
||||
209
public/service-continuity.html
Normal file
209
public/service-continuity.html
Normal file
@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Standard Continuita di Servizio — NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Segoe UI',-apple-system,sans-serif;font-size:14.5px;line-height:1.7;color:#1A1A2E;background:#F5F7FA}
|
||||
.page{max-width:960px;margin:0 auto;background:#fff;box-shadow:0 4px 40px rgba(0,0,0,.08)}
|
||||
.doc-header{background:linear-gradient(135deg,#0D1B2A 0%,#1565C0 100%);color:#fff;padding:40px 48px}
|
||||
.header-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:22px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.brand-icon{width:44px;height:44px;border-radius:10px;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:rgba(255,255,255,.8)}
|
||||
.brand-name{font-size:1rem;font-weight:800}
|
||||
.brand-sub{font-size:.68rem;opacity:.5}
|
||||
.doc-meta{text-align:right;font-size:.75rem;opacity:.5}
|
||||
.doc-meta strong{display:block;color:rgba(255,255,255,.8);opacity:1;font-size:.85rem}
|
||||
.doc-title{font-size:1.65rem;font-weight:800;letter-spacing:-.02em;line-height:1.2;margin-bottom:6px}
|
||||
.doc-title span{color:rgba(255,255,255,.8)}
|
||||
.doc-sub{font-size:.84rem;opacity:.6}
|
||||
.tag-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
|
||||
.tag{display:inline-flex;align-items:center;gap:5px;padding:4px 12px;border-radius:20px;font-size:.7rem;font-weight:600;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.18);color:rgba(255,255,255,.85)}
|
||||
.content{padding:36px 48px}
|
||||
h2{font-size:1.25rem;font-weight:800;color:#1565C0;margin:36px 0 14px;padding-bottom:6px;border-bottom:2px solid #eee}
|
||||
h2:first-child{margin-top:0}
|
||||
h2 i{margin-right:8px}
|
||||
h3{font-size:1rem;font-weight:700;color:#1565C0;margin:22px 0 8px}
|
||||
p{margin:8px 0}ul,ol{margin:8px 0 8px 20px}li{margin:4px 0}
|
||||
.info-box{background:#E8F5E9;border-left:4px solid #2E7D32;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem}
|
||||
.info-box i{color:#2E7D32;margin-right:6px}
|
||||
.warn-box{background:#FFF3E0;border-left:4px solid #F57F17;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem}
|
||||
.warn-box i{color:#F57F17;margin-right:6px}
|
||||
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:.82rem}
|
||||
th{background:#f5f5f5;padding:10px 12px;text-align:left;font-weight:700;border-bottom:2px solid #ddd;font-size:.75rem;text-transform:uppercase;letter-spacing:.5px;color:#333}
|
||||
td{padding:9px 12px;border-bottom:1px solid #F0F2F5;vertical-align:top}
|
||||
tr:hover td{background:#FAFBFC}
|
||||
code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:.8rem;font-family:'Courier New',monospace;color:#333}
|
||||
pre{background:#1A1A2E;color:#E0E4E8;padding:16px 20px;border-radius:8px;overflow-x:auto;font-size:.78rem;line-height:1.5;margin:12px 0}
|
||||
.layer{display:grid;grid-template-columns:60px 1fr;gap:0;margin:16px 0}
|
||||
.layer-num{background:#1565C0;color:#fff;font-weight:800;font-size:1.1rem;display:flex;align-items:center;justify-content:center;border-radius:8px 0 0 8px}
|
||||
.layer-body{background:#f9f9f9;padding:16px 20px;border-radius:0 8px 8px 0;border:1px solid #eee;border-left:none}
|
||||
.layer-body h4{margin:0 0 6px;color:#1565C0;font-size:.95rem}
|
||||
.badge-ok{display:inline-block;background:#2E7D32;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.badge-warn{display:inline-block;background:#F57F17;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.badge-crit{display:inline-block;background:#C62828;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.doc-footer{background:#f5f5f5;padding:24px 48px;border-top:1px solid #ddd;font-size:.78rem;color:#5F6B7A;display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px}
|
||||
@media print{body{background:#fff}.page{box-shadow:none}}
|
||||
@media(max-width:700px){.content,.doc-header,.doc-footer{padding:20px}.layer{grid-template-columns:1fr}.layer-num{border-radius:8px 8px 0 0;padding:8px}.layer-body{border-radius:0 0 8px 8px;border-left:1px solid #eee}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="doc-header">
|
||||
<div class="header-top">
|
||||
<div class="brand">
|
||||
<div class="brand-icon"><i class="fas fa-shield-halved"></i></div>
|
||||
<div><div class="brand-name">NIS2 Agile</div><div class="brand-sub">Cybersecurity Compliance NIS2</div></div>
|
||||
</div>
|
||||
<div class="doc-meta"><strong>Standard Operativo</strong>Continuita di Servizio</div>
|
||||
</div>
|
||||
<div class="doc-title">Strategia di <span>Continuita di Servizio</span></div>
|
||||
<div class="doc-sub">Architettura di resilienza, monitoraggio, auto-recovery e protezione dei dati</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag"><i class="fas fa-calendar"></i> Aprile 2026</span>
|
||||
<span class="tag"><i class="fas fa-microchip"></i> PHP+MySQL+Qdrant</span>
|
||||
<span class="tag"><i class="fas fa-server"></i> Hetzner CPX31</span>
|
||||
<span class="tag"><i class="fas fa-heartbeat"></i> 99.9% SLA Target</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
<h2><i class="fas fa-sitemap"></i> 1. Architettura di Servizio</h2>
|
||||
<p><strong>NIS2 Agile</strong> e composto dai seguenti servizi gestiti sulla piattaforma Hetzner condivisa con gli altri prodotti della suite Agile Technology.</p>
|
||||
<table><tr><th>Servizio</th><th>Stack</th><th>Healthcheck</th></tr><tr><td><code>nis2-app</code></td><td>PHP+MySQL+Qdrant</td><td><span class="badge-ok">/health</span></td></tr><tr><td><code>MySQL/PostgreSQL</code></td><td>Database condiviso</td><td><span class="badge-ok">ping</span></td></tr><tr><td><code>Qdrant</code></td><td>Vector DB (embeddings)</td><td><span class="badge-ok">HTTP 200</span></td></tr><tr><td><code>AgileHub (NEXUS)</code></td><td>Ticket + AI + Notifiche</td><td><span class="badge-ok">/health</span></td></tr></table>
|
||||
|
||||
<div class="info-box">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Deploy a caldo</strong>: le modifiche al codice sorgente vengono applicate via bind mount Docker o PM2 reload. Nessun rebuild necessario per aggiornamenti ordinari.
|
||||
</div>
|
||||
|
||||
<h2><i class="fas fa-layer-group"></i> 2. Strategia di Resilienza a 7 Livelli</h2>
|
||||
|
||||
<div class="layer"><div class="layer-num">L1</div><div class="layer-body">
|
||||
<h4><i class="fas fa-rotate"></i> Auto-Restart Container / PM2</h4>
|
||||
<p>Ogni servizio ha <code>restart: unless-stopped</code> (Docker) o <code>pm2 --watch</code> (Node.js). Se un processo crasha, viene riavviato automaticamente entro 10-30 secondi.</p>
|
||||
<p><strong>Tempo di recovery</strong>: < 30 secondi</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L2</div><div class="layer-body">
|
||||
<h4><i class="fas fa-heartbeat"></i> Healthcheck Nativo</h4>
|
||||
<p>Ogni servizio espone un endpoint <code>/health</code> o <code>/api/health</code> verificato ogni 30 secondi. Se il check fallisce 3 volte consecutive, il container/processo viene ricreato automaticamente.</p>
|
||||
<p><strong>Rileva</strong>: crash applicativo, memoria esaurita, deadlock, loop infinito.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L3</div><div class="layer-body">
|
||||
<h4><i class="fas fa-database"></i> Database Retry con Reconnect</h4>
|
||||
<p>Tutti i servizi implementano retry automatico su errori transitori del database (MySQL/PostgreSQL):</p>
|
||||
<ul>
|
||||
<li><strong>2006</strong> — Server has gone away (reconnect)</li>
|
||||
<li><strong>2013</strong> — Lost connection during query (retry)</li>
|
||||
<li><strong>1213</strong> — Deadlock found (retry con backoff)</li>
|
||||
</ul>
|
||||
<p>Strategia: 2 retry con backoff esponenziale + reconnect automatico.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L4</div><div class="layer-body">
|
||||
<h4><i class="fas fa-cloud"></i> Resilienza API Esterne</h4>
|
||||
<p>Le chiamate ad API esterne (Claude AI, ElevenLabs TTS, Voyage embeddings) hanno protezione a 3 livelli:</p>
|
||||
<ol>
|
||||
<li><strong>Retry con backoff</strong>: 3 tentativi su errori transitori (429, 500, 502, 503)</li>
|
||||
<li><strong>Degradazione graceful</strong>: se l'AI non risponde, il prodotto funziona con i dati locali</li>
|
||||
<li><strong>Frontend fallback</strong>: messaggio "AI temporaneamente non disponibile" con suggerimenti alternativi</li>
|
||||
</ol>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L5</div><div class="layer-body">
|
||||
<h4><i class="fas fa-bell"></i> Error Alerting in Tempo Reale</h4>
|
||||
<p>Ogni errore critico genera una notifica AgileHub (campanella) per l'admin e, se configurato, un'email di alert. L'admin vede l'errore entro 30 secondi via polling badge.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L6</div><div class="layer-body">
|
||||
<h4><i class="fas fa-eye"></i> Monitoraggio Esterno AgileHub</h4>
|
||||
<p>Il cron AgileHub (<code>ticket-agent-cron.sh</code>) verifica lo stato di tutti i container ogni 2 minuti. Se un prodotto non e raggiungibile, viene segnalato nel log e inviata notifica.</p>
|
||||
<p>Complementare ai healthcheck interni: rileva problemi di rete, proxy Apache, DNS.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L7</div><div class="layer-body">
|
||||
<h4><i class="fas fa-broom"></i> Manutenzione Preventiva</h4>
|
||||
<p>Script automatici prevengono il degrado:</p>
|
||||
<table>
|
||||
<tr><th>Operazione</th><th>Frequenza</th><th>Funzione</th></tr>
|
||||
<tr><td>Disk monitor</td><td>Ogni 6 ore</td><td>Alert email se disco > 85%</td></tr>
|
||||
<tr><td>Cleanup</td><td>Settimanale</td><td>Elimina file temporanei, Docker prune</td></tr>
|
||||
<tr><td>SSL renewal</td><td>Settimanale</td><td>Rinnovo automatico Let's Encrypt</td></tr>
|
||||
<tr><td>Claude auth</td><td>Orario</td><td>Refresh token OAuth Claude Code</td></tr>
|
||||
</table>
|
||||
</div></div>
|
||||
|
||||
<h2><i class="fas fa-shield-halved"></i> 3. Protezione da Abuso</h2>
|
||||
<h3>Rate Limiting</h3>
|
||||
<table>
|
||||
<tr><th>Endpoint</th><th>Limite</th><th>Finestra</th><th>Tipo</th></tr>
|
||||
<tr><td>Login</td><td>5 tentativi</td><td>15 min</td><td>Per IP + lockout</td></tr>
|
||||
<tr><td>API generiche</td><td>100 req</td><td>1 ora</td><td>Per chiave/utente</td></tr>
|
||||
<tr><td>Password reset</td><td>3 req</td><td>1 ora</td><td>Per email</td></tr>
|
||||
<tr><td>Webhook esterni</td><td>50 req</td><td>1 min</td><td>Per IP</td></tr>
|
||||
</table>
|
||||
|
||||
<h2><i class="fas fa-wrench"></i> 4. Modalita Manutenzione</h2>
|
||||
<p>Quando il sistema AgileHub applica un fix approvato, il <strong>maintenance mode</strong> si attiva automaticamente:</p>
|
||||
<ol>
|
||||
<li>Il cron agent crea il flag <code>/tmp/maintenance-NIS2.flag</code></li>
|
||||
<li>Apache rileva il flag e mostra la pagina di manutenzione a tutti gli utenti</li>
|
||||
<li>Il fix viene applicato (Claude nel container DevEnv)</li>
|
||||
<li>Il flag viene rimosso — il prodotto torna online</li>
|
||||
<li>La pagina di manutenzione si auto-aggiorna e reindirizza gli utenti</li>
|
||||
</ol>
|
||||
<div class="warn-box">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Nessun intervento manuale richiesto</strong>: il maintenance mode e completamente automatico. L'utente vede un messaggio professionale e il sistema torna online da solo.
|
||||
</div>
|
||||
|
||||
<h2><i class="fas fa-exclamation-triangle"></i> 5. Matrice Rischi e Mitigazione</h2>
|
||||
<table>
|
||||
<tr><th>Scenario</th><th>Probabilita</th><th>Impatto</th><th>Mitigazione</th><th>Recovery</th></tr>
|
||||
<tr><td>Crash singolo servizio</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Auto-restart Docker/PM2</td><td>< 30s</td></tr>
|
||||
<tr><td>Database restart</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>DB retry + reconnect</td><td>< 5s</td></tr>
|
||||
<tr><td>AI non disponibile</td><td><span class="badge-warn">Media</span></td><td><span class="badge-ok">Basso</span></td><td>3 retry + graceful</td><td>Funzioni base intatte</td></tr>
|
||||
<tr><td>Disco pieno</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Monitor 6h + cleanup</td><td>Alert prima del 85%</td></tr>
|
||||
<tr><td>SSL scaduto</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Auto-renewal certbot</td><td>Rinnovo 30gg prima</td></tr>
|
||||
<tr><td>Attacco brute force</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Rate limit + lockout</td><td>Blocco 15 min</td></tr>
|
||||
<tr><td>Errore deployment</td><td><span class="badge-warn">Media</span></td><td><span class="badge-crit">Alto</span></td><td>Maintenance mode + git revert</td><td>< 2 min rollback</td></tr>
|
||||
</table>
|
||||
|
||||
<h2><i class="fas fa-handshake"></i> 6. Service Level Agreement</h2>
|
||||
<table>
|
||||
<tr><th>Metrica</th><th>Target</th><th>Misurazione</th></tr>
|
||||
<tr><td>Uptime servizio</td><td><strong>99.9%</strong> (8.7h downtime/anno)</td><td>Health monitor + log</td></tr>
|
||||
<tr><td>Recovery da crash</td><td>< 30 secondi</td><td>Docker restart + healthcheck</td></tr>
|
||||
<tr><td>Recovery da aggiornamento</td><td>< 5 minuti</td><td>Maintenance mode + deploy</td></tr>
|
||||
<tr><td>Rilevamento downtime</td><td>< 5 minuti</td><td>Cron monitor + email alert</td></tr>
|
||||
<tr><td>Tempo risposta API (p95)</td><td>< 500ms</td><td>Health endpoint response time</td></tr>
|
||||
</table>
|
||||
|
||||
<h2><i class="fas fa-clipboard-check"></i> 7. Checklist Operativa</h2>
|
||||
<h3>Verifica automatica (quotidiana)</h3>
|
||||
<ul>
|
||||
<li>Tutti i container/processi in stato healthy/online</li>
|
||||
<li>Campanella AgileHub — nessuna notifica di errore sistema</li>
|
||||
<li>Email — nessun alert da health monitor</li>
|
||||
</ul>
|
||||
<h3>Prima di un aggiornamento manuale</h3>
|
||||
<ol>
|
||||
<li>Verificare che non ci siano utenti attivi (o attivare maintenance mode)</li>
|
||||
<li>Eseguire l'aggiornamento</li>
|
||||
<li>Verificare tutti i servizi: <code>docker compose ps</code> o <code>pm2 list</code></li>
|
||||
<li>Controllare i log per errori</li>
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
<div class="doc-footer">
|
||||
<div>© 2026 NIS2 Agile — <a href="https://nis2.agile.software" style="color:#1565C0">nis2.agile.software</a></div>
|
||||
<div>Standard Continuita di Servizio v1.0 • Aprile 2026 • Agile Technology SRL</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user