Files
akademiawodociagowca/docs/Webinar-platform_Instrukcja_Uruchomienia_PL.html
2026-09-02 08:21:27 +02:00

1515 lines
108 KiB
HTML

<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Platforma webinarowa - instrukcja uruchomienia</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--blue-500:#2C8ABD; --blue-600:#2272A0; --blue-050:#EAF4FA;
--navy-700:#23415B; --navy-800:#1A3145; --navy-900:#12233A;
--aqua-300:#8AC4DC; --aqua-400:#6BB3D2;
--ok-500:#16935C; --ok-050:#E8F5EF; --warn-500:#D9870E; --warn-050:#FBF2E2; --alarm-500:#D8392F; --alarm-050:#FBEAE8;
--page:#F4F8FB; --card:#FFFFFF; --sunken:#EDF2F7;
--text-strong:#0F1A28; --text-body:#2E4157; --text-muted:#5C7186; --text-faint:#8598AB;
--border-subtle:#E3EAF1; --border-default:#D2DCE6; --border-strong:#B9C7D6;
--shadow-sm:0 1px 2px rgba(15,26,40,.06),0 1px 3px rgba(15,26,40,.08);
--shadow-md:0 4px 12px rgba(15,26,40,.10);
--font-sans:'IBM Plex Sans',-apple-system,'Segoe UI',system-ui,sans-serif;
--font-mono:'IBM Plex Mono',ui-monospace,Consolas,monospace;
--radius-md:8px; --radius-lg:12px;
--sidebar-w:288px;
}
@media (prefers-color-scheme: dark){
:root{
--page:#0E1826; --card:#16273B; --sunken:#0F1E30;
--blue-050:#153349; --ok-050:#11311F; --warn-050:#372A10; --alarm-050:#3A1A17;
--text-strong:#F0F5FA; --text-body:#C6D4E2; --text-muted:#8FA3B8; --text-faint:#66798D;
--border-subtle:#22364C; --border-default:#2C425A; --border-strong:#3D556F;
--shadow-sm:0 1px 2px rgba(0,0,0,.35); --shadow-md:0 4px 14px rgba(0,0,0,.45);
}
}
:root[data-theme="dark"]{
--page:#0E1826; --card:#16273B; --sunken:#0F1E30;
--blue-050:#153349; --ok-050:#11311F; --warn-050:#372A10; --alarm-050:#3A1A17;
--text-strong:#F0F5FA; --text-body:#C6D4E2; --text-muted:#8FA3B8; --text-faint:#66798D;
--border-subtle:#22364C; --border-default:#2C425A; --border-strong:#3D556F;
--shadow-sm:0 1px 2px rgba(0,0,0,.35); --shadow-md:0 4px 14px rgba(0,0,0,.45);
}
:root[data-theme="light"]{
--page:#F4F8FB; --card:#FFFFFF; --sunken:#EDF2F7;
--blue-050:#EAF4FA; --ok-050:#E8F5EF; --warn-050:#FBF2E2; --alarm-050:#FBEAE8;
--text-strong:#0F1A28; --text-body:#2E4157; --text-muted:#5C7186; --text-faint:#8598AB;
--border-subtle:#E3EAF1; --border-default:#D2DCE6; --border-strong:#B9C7D6;
--shadow-sm:0 1px 2px rgba(15,26,40,.06),0 1px 3px rgba(15,26,40,.08);
--shadow-md:0 4px 12px rgba(15,26,40,.10);
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:var(--font-sans);background:var(--page);color:var(--text-body);font-size:16px;line-height:1.58;-webkit-text-size-adjust:100%;text-size-adjust:100%}
h1,h2,h3,h4{color:var(--text-strong);letter-spacing:-0.02em;text-wrap:balance;line-height:1.25}
h1{font-size:34px;font-weight:700}
h2{font-size:26px;font-weight:600;margin-bottom:12px}
h3{font-size:19px;font-weight:600;margin-bottom:8px;margin-top:22px}
h4{font-size:16px;font-weight:600;margin-bottom:6px}
p{margin-bottom:12px;max-width:76ch}
ul,ol{margin:0 0 12px 22px}
li{margin-bottom:5px}
a{color:var(--blue-600)}
strong{color:var(--text-strong)}
code{font-family:var(--font-mono);font-size:.9em;background:var(--sunken);padding:2px 6px;border-radius:4px;color:var(--text-strong);word-break:break-word}
.mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums}
pre{background:var(--navy-900);color:#DCE7F1;border-radius:var(--radius-md);padding:14px 16px;overflow-x:auto;font-family:var(--font-mono);font-size:13.5px;line-height:1.6;margin:14px 0;-webkit-overflow-scrolling:touch}
pre code{background:none;padding:0;color:inherit;font-size:inherit}
pre .c{color:#8FA9C2}
/* ---------- shell ---------- */
.shell{display:flex;min-height:100vh}
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--card);border-right:1px solid var(--border-subtle);position:fixed;top:0;bottom:0;overflow-y:auto;padding:22px 14px;z-index:20}
.logo{display:flex;align-items:center;gap:10px;padding:0 10px 18px;border-bottom:1px solid var(--border-subtle);margin-bottom:14px}
.logo svg{flex-shrink:0}
.logo .name{font-size:18px;font-weight:700;color:var(--navy-700)}
:root[data-theme="dark"] .logo .name{color:#CFE4F2}
@media (prefers-color-scheme: dark){ .logo .name{color:#CFE4F2} }
:root[data-theme="light"] .logo .name{color:var(--navy-700)}
.logo .name b{color:var(--blue-500);font-weight:700}
.logo .sub{font-size:11px;color:var(--text-muted);letter-spacing:.06em;text-transform:uppercase}
.toc a{display:flex;gap:10px;align-items:baseline;padding:9px 10px;border-radius:var(--radius-md);text-decoration:none;color:var(--text-body);font-size:14.5px;line-height:1.3}
.toc a .num{font-family:var(--font-mono);font-size:11.5px;color:var(--text-faint);width:20px;flex-shrink:0}
.toc a:hover{background:var(--sunken)}
.toc a.active{background:var(--blue-050);color:var(--blue-600);font-weight:600}
.toc a.active .num{color:var(--blue-500)}
.side-actions{margin-top:16px;padding-top:14px;border-top:1px solid var(--border-subtle);display:flex;flex-direction:column;gap:8px}
.side-actions button{font-family:var(--font-sans);font-size:13px;padding:8px 10px;border:1px solid var(--border-default);background:var(--card);color:var(--text-body);border-radius:var(--radius-md);cursor:pointer;text-align:left}
.side-actions button:hover{background:var(--sunken)}
.side-actions button:focus-visible,.toc a:focus-visible,.navbtns a:focus-visible{outline:3px solid rgba(44,138,189,.45);outline-offset:1px}
.progress-track{position:fixed;top:0;left:var(--sidebar-w);right:0;height:3px;background:transparent;z-index:60}
.progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--blue-500),var(--aqua-400))}
main{margin-left:var(--sidebar-w);padding:44px 48px 80px;flex:1;max-width:calc(var(--sidebar-w) + 1080px);min-width:0}
.chapter{display:block}
.chapter + .chapter{margin-top:40px;padding-top:32px;border-top:1px solid var(--border-default)}
html.js-nav .chapter{display:none;animation:fadeIn .22s ease;margin-top:0;padding-top:0;border-top:none}
html.js-nav .chapter.visible{display:block}
@keyframes fadeIn{from{transform:translateY(6px)}to{transform:none}}
.mobilebar{display:none;position:sticky;top:0;z-index:40;background:var(--card);border-bottom:1px solid var(--border-subtle);padding:9px 14px;align-items:center;gap:12px;box-shadow:var(--shadow-sm)}
.mobilebar .burger{flex-shrink:0;width:42px;height:42px;border:1px solid var(--border-default);background:var(--card);border-radius:var(--radius-md);cursor:pointer;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:4px;padding:0}
.mobilebar .burger span{display:block;width:18px;height:2px;background:var(--text-body);border-radius:2px}
.mobilebar .burger:focus-visible{outline:3px solid rgba(44,138,189,.45);outline-offset:1px}
.mobilebar .mb-title{font-size:15px;font-weight:700;color:var(--text-strong);line-height:1.2;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mobilebar .mb-title b{color:var(--blue-500)}
.mobilebar .mb-step{margin-left:auto;font-family:var(--font-mono);font-size:12px;color:var(--text-muted);flex-shrink:0}
.backdrop{display:none;position:fixed;inset:0;background:rgba(15,26,40,.45);z-index:45}
.backdrop.show{display:block}
@media print{
.sidebar,.navbtns,.progress-track,.mobilebar,.backdrop{display:none!important}
main{margin:0;padding:10mm;max-width:none}
.chapter{display:block!important;page-break-after:always}
pre{white-space:pre-wrap;background:#F2F5F8;color:#12233A;border:1px solid #D2DCE6}
pre .c{color:#5C7186}
.diagram svg{min-width:0!important}
}
@media (max-width:1180px){
:root{--sidebar-w:248px}
main{padding:36px 28px 70px}
}
@media (max-width:900px){
.shell{flex-direction:column}
.sidebar{position:static;width:100%;border-right:none;border-bottom:1px solid var(--border-subtle)}
html.js-nav .mobilebar{display:flex}
html.js-nav .sidebar{position:fixed;top:0;bottom:0;left:0;width:min(320px,86vw);transform:translateX(-100%);transition:transform .22s ease;z-index:50;border-right:1px solid var(--border-subtle);border-bottom:none}
html.js-nav .sidebar.open{transform:none;box-shadow:var(--shadow-md)}
main{margin-left:0;padding:20px 16px 56px;max-width:none}
.progress-track{left:0}
h1{font-size:clamp(24px,6vw,32px)}
h2{font-size:clamp(20px,5vw,25px)}
h3{font-size:17.5px}
.lead{font-size:16.5px}
.card{padding:16px}
.grid2,.grid3{grid-template-columns:1fr}
.navbtns{flex-wrap:wrap}
.navbtns a{flex:1 1 auto;text-align:center}
.tabbar{flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;-webkit-overflow-scrolling:touch}
.tabbar button{white-space:nowrap;flex-shrink:0}
.diagram{padding:12px 10px}
.diagram svg{min-width:660px}
.diagram .cap::before{content:"Rysunek przewija się w bok. ";color:var(--blue-600);font-weight:600}
table.tbl{min-width:540px}
.steps .step{gap:12px}
pre{font-size:12.5px}
}
@media (prefers-reduced-motion: reduce){ .sidebar{transition:none} }
/* ---------- components ---------- */
.kicker{font-size:12px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--blue-500);margin-bottom:8px}
.lead{font-size:18.5px;color:var(--text-body);max-width:72ch}
.card{background:var(--card);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:20px 22px;box-shadow:var(--shadow-sm);margin:18px 0}
.card h3{margin-top:0}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin:18px 0}
.grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:18px 0}
.grid2 .card,.grid3 .card{margin:0}
.stat{background:var(--card);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:16px 18px;box-shadow:var(--shadow-sm)}
.stat .v{font-family:var(--font-mono);font-size:22px;font-weight:600;color:var(--text-strong);font-variant-numeric:tabular-nums;word-break:break-all}
.stat .l{font-size:13px;color:var(--text-muted);margin-top:2px}
.pill{display:inline-block;font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;white-space:nowrap}
.pill.ok{background:var(--ok-050);color:var(--ok-500)}
.pill.warn{background:var(--warn-050);color:var(--warn-500)}
.pill.alarm{background:var(--alarm-050);color:var(--alarm-500)}
.pill.info{background:var(--blue-050);color:var(--blue-600)}
.pill.neutral{background:var(--sunken);color:var(--text-muted)}
.note{border-left:3px solid var(--blue-500);background:var(--blue-050);border-radius:0 var(--radius-md) var(--radius-md) 0;padding:12px 16px;margin:16px 0;font-size:15px}
.note.warn{border-left-color:var(--warn-500);background:var(--warn-050)}
.note.ok{border-left-color:var(--ok-500);background:var(--ok-050)}
.note.alarm{border-left-color:var(--alarm-500);background:var(--alarm-050)}
.note p:last-child{margin-bottom:0}
.note ul{margin-bottom:0}
table.tbl{width:100%;border-collapse:collapse;font-size:14.5px;margin:14px 0}
table.tbl th{font-size:12.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);font-weight:600;text-align:left;padding:8px 10px;border-bottom:2px solid var(--border-default)}
table.tbl td{padding:9px 10px;border-bottom:1px solid var(--border-subtle);vertical-align:top}
table.tbl td.mono{font-size:13px}
.tblwrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.navbtns{display:flex;justify-content:space-between;gap:12px;margin-top:36px;padding-top:18px;border-top:1px solid var(--border-subtle)}
.navbtns a{display:inline-block;padding:11px 20px;border-radius:var(--radius-md);text-decoration:none;font-weight:600;font-size:14.5px}
.navbtns a.next{background:var(--blue-500);color:#fff}
.navbtns a.next:hover{background:var(--blue-600)}
.navbtns a.prev{border:1px solid var(--border-default);color:var(--text-body)}
.navbtns a.prev:hover{background:var(--sunken)}
.tabs{margin:20px 0}
.tabbar{display:flex;flex-wrap:wrap;gap:6px;border-bottom:2px solid var(--border-subtle);margin-bottom:0}
.tabbar button{font-family:var(--font-sans);font-size:14px;font-weight:600;padding:9px 14px;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-muted);cursor:pointer;margin-bottom:-2px}
.tabbar button.active{color:var(--blue-600);border-bottom-color:var(--blue-500)}
.tabbar button:hover{color:var(--text-strong)}
.tabpane{display:none;padding:18px 2px 4px}
.tabpane.active{display:block}
.acc{border:1px solid var(--border-subtle);border-radius:var(--radius-md);margin:10px 0;background:var(--card)}
.acc>button{width:100%;font-family:var(--font-sans);font-size:15px;font-weight:600;color:var(--text-strong);text-align:left;padding:13px 16px;background:none;border:none;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:10px}
.acc>button::after{content:"+";font-family:var(--font-mono);font-size:17px;color:var(--text-faint)}
.acc.open>button::after{content:"-"}
.acc>.body{display:none;padding:2px 16px 14px;font-size:14.5px}
.acc.open>.body{display:block}
.steps{counter-reset:step;display:flex;flex-direction:column;gap:0;margin:18px 0}
.step{display:flex;gap:16px;position:relative;padding-bottom:22px}
.step:last-child{padding-bottom:0}
.step::before{counter-increment:step;content:counter(step);flex-shrink:0;width:32px;height:32px;border-radius:999px;background:var(--blue-500);color:#fff;font-family:var(--font-mono);font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:center;z-index:1}
.step:not(:last-child)::after{content:"";position:absolute;left:15px;top:32px;bottom:0;width:2px;background:var(--border-default)}
.step h4{margin-bottom:3px}
.step .sbody{min-width:0;flex:1}
.step p{margin-bottom:8px;font-size:14.5px}
.step p:last-child{margin-bottom:0}
.diagram{background:var(--card);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:16px;margin:18px 0;overflow-x:auto;box-shadow:var(--shadow-sm);-webkit-overflow-scrolling:touch}
.diagram svg{display:block;margin:0 auto;max-width:100%;height:auto}
.diagram .cap{text-align:center;font-size:13px;color:var(--text-muted);margin-top:10px}
.flowline{stroke-dasharray:7 6;animation:dashmove 1.1s linear infinite}
@keyframes dashmove{to{stroke-dashoffset:-13}}
@media (prefers-reduced-motion: reduce){
.flowline{animation:none}
html{scroll-behavior:auto}
.chapter{animation:none}
}
svg text{font-family:var(--font-sans)}
.shot{margin:18px 0}
.shot figure{margin:0;background:var(--card);border:1px solid var(--border-default);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-sm)}
.shot img{display:block;width:100%;height:auto;background:var(--sunken)}
.shot figcaption{font-size:13px;color:var(--text-muted);padding:9px 14px;border-top:1px solid var(--border-subtle);background:var(--sunken)}
.shot figcaption b{color:var(--text-body);font-weight:600}
.shot.tall img{max-height:760px;object-fit:cover;object-position:top}
@media print{ .shot img{max-height:150mm;object-fit:contain;object-position:top} }
.fill{margin:14px 0;border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}
.fill .fh{background:var(--blue-050);color:var(--blue-600);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:7px 14px;border-bottom:1px solid var(--border-default)}
.fill table{width:100%;border-collapse:collapse;font-size:14px;margin:0}
.fill td{padding:8px 14px;border-bottom:1px solid var(--border-subtle);vertical-align:top}
.fill tr:last-child td{border-bottom:none}
.fill td:first-child{width:34%;color:var(--text-muted);font-weight:500}
.fill td:last-child{font-family:var(--font-mono);font-size:13px;color:var(--text-strong)}
.fill td.txt{font-family:var(--font-sans);font-size:14px}
.checklist{list-style:none;margin-left:0}
.checklist li{display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--border-subtle);margin-bottom:0}
.checklist li:last-child{border-bottom:none}
.checklist li::before{content:"";flex-shrink:0;width:17px;height:17px;margin-top:3px;border:2px solid var(--border-strong);border-radius:4px}
.footer-sig{margin-top:40px;font-size:12.5px;color:var(--text-faint);border-top:1px solid var(--border-subtle);padding-top:14px}
.side-actions,.tabbar{display:none}
html.js-nav .side-actions{display:flex}
html.js-nav .tabbar{display:flex}
.tabpane{display:block}
html.js-nav .tabpane{display:none}
html.js-nav .tabpane.active{display:block}
.acc>.body{display:block}
html.js-nav .acc>.body{display:none}
html.js-nav .acc.open>.body{display:block}
html.js-nav .acc>button::after{content:"+"}
html.js-nav .acc.open>button::after{content:"-"}
.acc>button::after{content:""}
</style>
<script>document.documentElement.className += ' js-nav';</script>
</head>
<body>
<div class="progress-track"><div class="progress-bar" id="progressBar"></div></div>
<header class="mobilebar">
<button class="burger" id="navToggle" type="button" aria-label="Otwórz spis rozdziałów" aria-expanded="false" aria-controls="sidebar"><span></span><span></span><span></span></button>
<div class="mb-title">Platforma <b>webinarowa</b></div>
<div class="mb-step" id="mbStep">01 / 13</div>
</header>
<div class="backdrop" id="navBackdrop" hidden></div>
<div class="shell">
<nav class="sidebar" id="sidebar" aria-label="Spis rozdziałów">
<div class="logo">
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden="true">
<rect x="3" y="6" width="28" height="19" rx="3" fill="#2C8ABD"/>
<path d="M14 12.5v6l5.5-3-5.5-3Z" fill="#fff"/>
<path d="M11 29h12" stroke="#8AC4DC" stroke-width="2.2" stroke-linecap="round"/>
</svg>
<div>
<div class="name">Platforma <b>webinarowa</b></div>
<div class="sub">Instrukcja uruchomienia</div>
</div>
</div>
<div class="toc" id="toc">
<a href="#ch1"><span class="num">01</span>Wprowadzenie</a>
<a href="#ch2"><span class="num">02</span>Uruchomienie środowiska</a>
<a href="#ch3"><span class="num">03</span>Pierwsze logowanie</a>
<a href="#ch4"><span class="num">04</span>Konfiguracja instancji</a>
<a href="#ch5"><span class="num">05</span>Tryb bezpieczny wysyłki</a>
<a href="#ch7"><span class="num">06</span>Webinar testowy: dane</a>
<a href="#ch8"><span class="num">07</span>Utworzenie webinaru</a>
<a href="#ch9"><span class="num">08</span>Wideo i prelegenci</a>
<a href="#ch10"><span class="num">09</span>Publikacja i zapisy</a>
<a href="#ch11"><span class="num">10</span>Przeprowadzenie webinaru</a>
<a href="#ch12"><span class="num">11</span>Po webinarze</a>
<a href="#ch13"><span class="num">12</span>Scenariusz testowy</a>
<a href="#ch13"><span class="num">13</span>Rozwiązywanie problemów</a>
</div>
<div class="side-actions">
<button id="themeToggle" type="button">Przełącz motyw jasny / ciemny</button>
<button id="printAll" type="button">Drukuj całość / zapisz PDF</button>
</div>
</nav>
<main>
<!-- ============ 1 ============ -->
<section class="chapter" id="ch1">
<div class="kicker">Rozdział 01</div>
<h1>Instrukcja uruchomienia i przeprowadzenia webinaru</h1>
<p class="lead">Dokument prowadzi krok po kroku od pustego środowiska do zakończonego webinaru z nagraniem i certyfikatami. Opisuje uruchomienie lokalne, na <code>localhost</code>, przeznaczone do testów przed wdrożeniem u klienta.</p>
<div class="note">
<p><strong>Dla kogo.</strong> Dla osoby, która ma uruchomić platformę na własnym komputerze i przeprowadzić na niej pełny webinar testowy: od utworzenia wydarzenia, przez zapis uczestnika, spotkanie na żywo, aż po podsumowanie i certyfikat.</p>
</div>
<h2>Co obejmuje ten dokument</h2>
<p>Rozdziały 2-5 to przygotowanie środowiska, wykonywane <strong>raz</strong>. Rozdziały 6-10 to cykl życia pojedynczego webinaru, powtarzany przy każdym wydarzeniu. Rozdział 12 to gotowy scenariusz testu, rozdział 12 to katalog typowych problemów.</p>
<div class="grid3">
<div class="stat"><div class="v">2-5</div><div class="l">Przygotowanie środowiska, jednorazowo</div></div>
<div class="stat"><div class="v">6-10</div><div class="l">Cykl życia webinaru, przy każdym wydarzeniu</div></div>
<div class="stat"><div class="v">11-12</div><div class="l">Test end-to-end i diagnostyka</div></div>
</div>
<h2>Czym jest ta platforma</h2>
<p>Aplikacja obsługuje pełny cykl życia szkolenia online: rejestrację uczestnika, przypomnienia mailowe i SMS, spotkanie wideo, interakcje na żywo (czat, pytania, zapisy na zadania, zapytania ofertowe), nagranie, podsumowanie i certyfikaty uczestnictwa.</p>
<p>Model wdrożenia zakłada <strong>osobną instancję dla każdego klienta</strong>, na jego serwerze i jego domenie. Jedno repozytorium, jedna wersja kodu, a różnice między klientami wyłącznie w konfiguracji.</p>
<h2>Mapa cyklu życia wydarzenia</h2>
<div class="diagram">
<svg viewBox="0 0 900 190" role="img" aria-label="Diagram: od utworzenia webinaru, przez publikację, zapisy, przypomnienia i spotkanie, do nagrania, podsumowania i certyfikatu">
<defs>
<marker id="ar1" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#2C8ABD"/></marker>
</defs>
<g>
<rect x="12" y="58" width="120" height="56" rx="8" fill="#EAF4FA" stroke="#2C8ABD" stroke-width="1.5"/>
<text x="72" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Utworzenie</text>
<text x="72" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">status DRAFT</text>
<rect x="168" y="58" width="120" height="56" rx="8" fill="#EAF4FA" stroke="#2C8ABD" stroke-width="1.5"/>
<text x="228" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Publikacja</text>
<text x="228" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">PUBLISHED</text>
<rect x="324" y="58" width="120" height="56" rx="8" fill="#FFFFFF" stroke="#B9C7D6" stroke-width="1.5"/>
<text x="384" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Zapisy</text>
<text x="384" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">+ przypomnienia</text>
<rect x="480" y="58" width="120" height="56" rx="8" fill="#E8F5EF" stroke="#16935C" stroke-width="1.5"/>
<text x="540" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Spotkanie</text>
<text x="540" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">wideo + interakcje</text>
<rect x="636" y="58" width="120" height="56" rx="8" fill="#FFFFFF" stroke="#B9C7D6" stroke-width="1.5"/>
<text x="696" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Nagranie</text>
<text x="696" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">obecność</text>
<rect x="792" y="58" width="96" height="56" rx="8" fill="#EAF4FA" stroke="#2C8ABD" stroke-width="1.5"/>
<text x="840" y="80" text-anchor="middle" font-size="13" font-weight="600" fill="#12233A">Podsum.</text>
<text x="840" y="98" text-anchor="middle" font-size="11.5" fill="#5C7186">certyfikat</text>
<line class="flowline" x1="134" y1="86" x2="164" y2="86" stroke="#2C8ABD" stroke-width="2" marker-end="url(#ar1)"/>
<line class="flowline" x1="290" y1="86" x2="320" y2="86" stroke="#2C8ABD" stroke-width="2" marker-end="url(#ar1)"/>
<line class="flowline" x1="446" y1="86" x2="476" y2="86" stroke="#2C8ABD" stroke-width="2" marker-end="url(#ar1)"/>
<line class="flowline" x1="602" y1="86" x2="632" y2="86" stroke="#2C8ABD" stroke-width="2" marker-end="url(#ar1)"/>
<line class="flowline" x1="758" y1="86" x2="788" y2="86" stroke="#2C8ABD" stroke-width="2" marker-end="url(#ar1)"/>
<text x="384" y="145" text-anchor="middle" font-size="11.5" fill="#8598AB">automat: przypomnienia wg reguł</text>
<text x="840" y="145" text-anchor="middle" font-size="11.5" fill="#8598AB">automat: 60 min po końcu</text>
</g>
</svg>
<div class="cap">Rysunek 1. Cykl życia webinaru. Kroki niebieskie wykonuje organizator, szare dzieją się same.</div>
</div>
<div class="navbtns"><span></span><a class="next" href="#ch2">Dalej: uruchomienie środowiska</a></div>
</section>
<!-- ============ 2 ============ -->
<section class="chapter" id="ch2">
<div class="kicker">Rozdział 02</div>
<h2>Uruchomienie środowiska</h2>
<p class="lead">Całość działa w Dockerze. Do testów lokalnych wystarczą cztery usługi, a wideo we własnym serwerze dokłada kolejne cztery.</p>
<h3>Wymagania</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Składnik</th><th>Wersja</th><th>Uwagi</th></tr></thead>
<tbody>
<tr><td>Docker Desktop</td><td class="mono">aktualny</td><td>Z obsługą Docker Compose v2</td></tr>
<tr><td>Wolna pamięć RAM</td><td class="mono">min. 4 GB</td><td>8 GB przy profilu <code>livekit</code>: Egress uruchamia przeglądarkę</td></tr>
<tr><td>Wolne porty</td><td class="mono">8080, 8081, 5432, 9000, 9001</td><td>Dodatkowo 7880, 7881 i zakres UDP przy LiveKit</td></tr>
<tr><td>Przeglądarka</td><td class="mono">Chrome / Edge</td><td>Do testu wideo najlepiej dwie różne albo okno prywatne</td></tr>
</tbody>
</table>
</div>
<h3>Krok po kroku</h3>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Przygotuj plik konfiguracyjny</h4>
<p>W katalogu projektu skopiuj wzorzec i uzupełnij sekrety. Plik <code>.env</code> nie trafia do repozytorium i jest inny dla każdej instancji.</p>
<pre><code><span class="c"># w katalogu Webinar-platform</span>
cp .env.example .env</code></pre>
<p>Uzupełnij co najmniej: <code>JWT_SECRET</code> (minimum 32 znaki, inaczej aplikacja nie wystartuje), <code>DB_PASSWORD</code>, <code>MINIO_SECRET_KEY</code> oraz <code>ADMIN_EMAIL</code> i <code>ADMIN_PASSWORD</code>.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Zbuduj i uruchom kontenery</h4>
<p>Pierwsze uruchomienie buduje obrazy i pobiera zależności, więc trwa kilka minut.</p>
<pre><code>docker compose up -d --build</code></pre>
<p>Startują cztery usługi: <code>postgres</code>, <code>minio</code>, <code>backend</code> i <code>frontend</code>. Backend sam wykonuje migracje Flyway, więc baza jest gotowa bez dodatkowych poleceń.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Sprawdź, czy wszystko wstało</h4>
<pre><code>docker compose ps
curl http://localhost:8080/api/health</code></pre>
<p>Poprawna odpowiedź to <code>{"status":"UP","version":"0.1.0"}</code>. Jeśli backend nie odpowiada, zajrzyj do logów: <code>docker compose logs -f backend</code>.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Uruchom wideo, jeśli chcesz testować spotkanie u siebie</h4>
<p>Serwer LiveKit siedzi w osobnym profilu, wyłączonym domyślnie: instancja w trybie NONE albo na Jitsi nie ma powodu go uruchamiać.</p>
<pre><code>docker compose --profile livekit up -d</code></pre>
<p>Profil dokłada cztery kontenery: <code>livekit</code> (serwer), <code>coturn</code> (TURN), <code>redis</code> i <code>livekit-egress</code> (nagrywanie). Same spotkania działają bez dwóch ostatnich, ale nagrywanie bez nich nie działa wcale.</p>
</div></div>
</div>
<div class="note warn">
<p><strong>Do uruchomienia lokalnego ustaw <code>LIVEKIT_NODE_IP=127.0.0.1</code> w <code>.env</code>.</strong> Bez tego serwer rozgłasza publiczny adres łącza, a przeglądarka na tej samej maszynie nie zestawi połączenia: uczestnik wejdzie do pokoju i zobaczy listę obecnych, ale nie popłynie ani obraz, ani dźwięk.</p>
</div>
<h3>Adresy po uruchomieniu</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Adres</th><th>Co to jest</th></tr></thead>
<tbody>
<tr><td class="mono">http://localhost:8081</td><td>Aplikacja: panel i strony publiczne</td></tr>
<tr><td class="mono">http://localhost:8080/api</td><td>API backendu</td></tr>
<tr><td class="mono">http://localhost:8080/api/health</td><td>Stan aplikacji</td></tr>
<tr><td class="mono">http://localhost:9001</td><td>Konsola MinIO (nagrania, materiały, pliki)</td></tr>
<tr><td class="mono">localhost:5432</td><td>PostgreSQL, do podłączenia klientem SQL</td></tr>
</tbody>
</table>
</div>
<div class="note alarm">
<p><strong>Nigdy nie używaj <code>docker compose down -v</code>.</strong> Flaga <code>-v</code> kasuje wolumeny, czyli bazę i MinIO razem z całą konfiguracją instancji: danymi SMTP, kluczami bramki SMS i kluczami dostawcy wideo. Tych wartości nie ma nigdzie indziej, bo konfiguracja poczty i SMS idzie wyłącznie przez panel, a nie przez <code>.env</code>. Do zatrzymania służy <code>docker compose down</code> bez flagi.</p>
</div>
<div class="navbtns"><a class="prev" href="#ch1">Wstecz</a><a class="next" href="#ch3">Dalej: pierwsze logowanie</a></div>
</section>
<!-- ============ 3 ============ -->
<section class="chapter" id="ch3">
<div class="kicker">Rozdział 03</div>
<h2>Pierwsze logowanie</h2>
<p class="lead">Konto administratora powstaje automatycznie przy pierwszym starcie backendu, na podstawie zmiennych z <code>.env</code>.</p>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Otwórz panel</h4>
<p>Wejdź na <code>http://localhost:8081</code>. Aplikacja przekieruje na ekran logowania pod <code>/logowanie</code>.</p>
<div class="shot"><figure><img src="img/03-logowanie.png" alt="Ekran logowania. Login to wartość ADMIN_EMAIL z pliku .env." loading="lazy"><figcaption><b>Ekran logowania.</b> Login to wartość ADMIN_EMAIL z pliku .env.</figcaption></figure></div>
</div></div>
<div class="step"><div class="sbody">
<h4>Zaloguj się danymi z <code>.env</code></h4>
<p>Login to wartość <code>ADMIN_EMAIL</code>, hasło to <code>ADMIN_PASSWORD</code>. Jeśli hasła nie podano, aplikacja wylosowała je i wypisała <strong>raz</strong> w logu przy pierwszym starcie:</p>
<pre><code>docker compose logs backend | grep -i has</code></pre>
</div></div>
<div class="step"><div class="sbody">
<h4>Zmień hasło, jeśli aplikacja o to poprosi</h4>
<p>Konto z hasłem wygenerowanym automatycznie ma ustawioną flagę wymuszającą zmianę przy pierwszym logowaniu.</p>
</div></div>
</div>
<div class="note warn">
<p><strong>Trzy pomyłki i konto czeka.</strong> Aplikacja liczy nieudane logowania: po pięciu próbach na ten sam adres konto jest blokowane na 15 minut. Wszystkie błędy logowania dają <strong>identyczny komunikat</strong>: złe hasło, nieznany adres i konto nieaktywne wyglądają tak samo, żeby nie dało się ustalić, które konta istnieją.</p>
</div>
<h3>Co widzisz po zalogowaniu</h3>
<div class="shot"><figure><img src="img/03-pulpit.png" alt="Podsumowanie. Ekran główny panelu: nadchodzące wydarzenia i sprawy czekające na obsługę." loading="lazy"><figcaption><b>Podsumowanie.</b> Ekran główny panelu: nadchodzące wydarzenia i sprawy czekające na obsługę.</figcaption></figure></div>
<p>Ekranem głównym panelu jest <strong>Podsumowanie</strong> pod adresem <code>/</code>. Pokazuje wyłącznie rzeczy, na które można zareagować: co się zbliża i co czeka na obsługę. Lewe menu ma następujące pozycje:</p>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Pozycja</th><th>Adres</th><th>Do czego służy</th><th>Rola</th></tr></thead>
<tbody>
<tr><td>Podsumowanie</td><td class="mono">/</td><td>Nadchodzące wydarzenia i sprawy do obsługi</td><td>wszyscy</td></tr>
<tr><td>Webinary</td><td class="mono">/webinary</td><td>Lista wydarzeń, tworzenie i edycja</td><td>wszyscy</td></tr>
<tr><td>Kontakty</td><td class="mono">/kontakty</td><td>Baza kontaktów, grupy, import, zaproszenia</td><td>wszyscy</td></tr>
<tr><td>Zapytania</td><td class="mono">/zapytania</td><td>Zapytania ofertowe ze wszystkich webinarów</td><td>wszyscy</td></tr>
<tr><td>Banery</td><td class="mono">/banery</td><td>Reklamy globalne instancji</td><td><span class="pill info">tylko ADMIN</span></td></tr>
<tr><td>Ustawienia</td><td class="mono">/ustawienia</td><td>Konfiguracja instancji</td><td><span class="pill info">tylko ADMIN</span></td></tr>
<tr><td>Użytkownicy</td><td class="mono">/uzytkownicy</td><td>Konta panelu</td><td><span class="pill info">tylko ADMIN</span></td></tr>
</tbody>
</table>
</div>
<div class="note">
<p><strong>Dwie role.</strong> <code>ADMIN</code> ma dostęp do wszystkiego. <code>MODERATOR</code> prowadzi webinary i obsługuje zapytania, ale nie widzi ustawień, banerów globalnych ani użytkowników. Ukrycie pozycji menu to wygoda, a nie zabezpieczenie: regułą jest kontrola po stronie backendu.</p>
</div>
<div class="navbtns"><a class="prev" href="#ch2">Wstecz</a><a class="next" href="#ch4">Dalej: konfiguracja instancji</a></div>
</section>
<!-- ============ 4 ============ -->
<section class="chapter" id="ch4">
<div class="kicker">Rozdział 04</div>
<h2>Konfiguracja instancji</h2>
<p class="lead">Wszystko poza sekretami startowymi ustawia się w panelu, w <code>/ustawienia</code>. Zmiany działają natychmiast, bez restartu aplikacji.</p>
<div class="note">
<p><strong>Dlaczego przez panel, a nie przez <code>.env</code>.</strong> Konfiguracja poczty i SMS trafia do tabeli <code>app_settings</code>, dzięki czemu klient zmienia ją sam, bez dostępu do serwera i bez restartu. W <code>.env</code> zostają wyłącznie rzeczy potrzebne do wystartowania: sekret JWT, hasło bazy i klucze MinIO.</p>
</div>
<h3>Podstrony ustawień</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Podstrona</th><th>Adres</th><th>Co ustawia</th></tr></thead>
<tbody>
<tr><td>Aplikacja</td><td class="mono">/ustawienia</td><td>Nazwa marki, adres publiczny, strefa czasowa, wideo, pokój, limity</td></tr>
<tr><td>Grafika</td><td class="mono">/ustawienia/grafika</td><td>Logo i ikona karty przeglądarki, z kadrowaniem</td></tr>
<tr><td>E-mail</td><td class="mono">/ustawienia/email</td><td>Serwer SMTP, nadawca, tryb bezpieczny, limit godzinowy</td></tr>
<tr><td>SMS</td><td class="mono">/ustawienia/sms</td><td>Bramka SMS, klucz API, tryb bezpieczny</td></tr>
<tr><td>Przypomnienia</td><td class="mono">/ustawienia/przypomnienia</td><td>Domyślny harmonogram przypomnień dla nowych webinarów</td></tr>
<tr><td>Zgody</td><td class="mono">/ustawienia/zgody</td><td>Treść zgód i ich wersja</td></tr>
</tbody>
</table>
</div>
<div class="shot tall"><figure><img src="img/04-ustawienia-aplikacja.png" alt="Ustawienia aplikacji. Adres publiczny, nazwa marki, strefa czasowa i wybór dostawcy wideo." loading="lazy"><figcaption><b>Ustawienia aplikacji.</b> Adres publiczny, nazwa marki, strefa czasowa i wybór dostawcy wideo.</figcaption></figure></div>
<h3>Minimum do przeprowadzenia webinaru</h3>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Ustaw adres publiczny instancji</h4>
<p>W <code>/ustawienia</code> pole <strong>APP_PUBLIC_URL</strong>. Lokalnie: <code>http://localhost:8081</code>.</p>
<p>To jest przedrostek każdego linku wysyłanego w mailu: potwierdzenia zapisu, przypomnienia, wejścia do pokoju. <strong>Nie ma wartości domyślnej</strong> i to jest celowe: adres <code>localhost</code> wstawiony automatycznie trafiłby do linków na produkcji i nic nie sygnalizowałoby błędu.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Nazwa marki i strefa czasowa</h4>
<p><strong>APP_BRAND_NAME</strong> pojawia się w panelu, na stronach publicznych i w mailach. <strong>APP_TIMEZONE</strong> to domyślna strefa nowych webinarów, standardowo <code>Europe/Warsaw</code>.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Skonfiguruj pocztę</h4>
<p>W <code>/ustawienia/email</code> uzupełnij serwer SMTP, port, login, hasło i nazwę nadawcy. Bez działającej poczty uczestnik nie dostanie potwierdzenia zapisu ani linku do pokoju.</p>
<p>Pod formularzem jest <strong>test wysyłki</strong>: wpisz adres i wyślij wiadomość próbną. Test zwraca pełną treść błędu z serwera SMTP, więc od razu widać, czy problem jest w porcie, w SSL czy w haśle.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Sprawdź tryb bezpieczny</h4>
<p>Zanim wyślesz cokolwiek, przeczytaj rozdział 5. Domyślnie cała wysyłka jest przekierowana na jeden adres i to jest zachowanie zamierzone.</p>
</div></div>
</div>
<div class="shot tall"><figure><img src="img/05-ustawienia-email.png" alt="Ustawienia poczty. Pole hasła jest puste, a obok widnieje oznaczenie „ustawione”. Pod formularzem jest test wysyłki." loading="lazy"><figcaption><b>Ustawienia poczty.</b> Pole hasła jest puste, a obok widnieje oznaczenie „ustawione”. Pod formularzem jest test wysyłki.</figcaption></figure></div>
<h3>Pole sekretne zachowuje się inaczej</h3>
<div class="note">
<p>Hasła i klucze API <strong>nigdy nie wracają z API</strong>. Pole jest zawsze puste, a obok widnieje oznaczenie „ustawione”, gdy wartość jest w bazie. Puste pole przy zapisie oznacza „nie zmieniaj”, a nie „wyczyść”. Dzięki temu zapis formularza nie kasuje hasła, którego nigdy nie widziałeś.</p>
</div>
<div class="navbtns"><a class="prev" href="#ch3">Wstecz</a><a class="next" href="#ch5">Dalej: tryb bezpieczny wysyłki</a></div>
</section>
<!-- ============ 5 ============ -->
<section class="chapter" id="ch5">
<div class="kicker">Rozdział 05</div>
<h2>Tryb bezpieczny wysyłki</h2>
<p class="lead">Najważniejszy rozdział dla testów. Domyślnie włączony bezpiecznik kieruje całą pocztę i wszystkie SMS-y na jeden wskazany adres.</p>
<div class="note alarm">
<p><strong>Ten mechanizm powstał po realnym incydencie.</strong> Zadanie podsumowań potraktowało webinary z danymi testowymi jako zakończone i wysłało 12 wiadomości przez produkcyjny serwer SMTP klienta, w tym 10 na nieistniejące adresy. <strong>Dane testowe z terminem w przeszłości wystarczą, żeby scheduler ruszył.</strong></p>
</div>
<h3>Jak to działa</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Ustawienie</th><th>Domyślnie</th><th>Znaczenie</th></tr></thead>
<tbody>
<tr><td class="mono">MAIL_SAFE_MODE</td><td><span class="pill ok">true</span></td><td>Cała poczta idzie na adres z <code>MAIL_SAFE_RECIPIENT</code></td></tr>
<tr><td class="mono">MAIL_SAFE_RECIPIENT</td><td>puste</td><td>Adres zastępczy odbiorcy</td></tr>
<tr><td class="mono">SMS_SAFE_MODE</td><td><span class="pill ok">true</span></td><td>Wszystkie SMS-y na numer z <code>SMS_SAFE_RECIPIENT</code></td></tr>
<tr><td class="mono">MAIL_RATE_LIMIT_PER_HOUR</td><td class="mono">100</td><td>Bezpiecznik ilościowy poczty</td></tr>
<tr><td class="mono">SMS_RATE_LIMIT_PER_HOUR</td><td class="mono">20</td><td>Bezpiecznik ilościowy SMS</td></tr>
</tbody>
</table>
</div>
<div class="note warn">
<p><strong>Tryb włączony bez ustawionego adresu wstrzymuje wysyłkę</strong>, zamiast przepuścić ją do prawdziwego odbiorcy. Jeśli maile „nie wychodzą”, sprawdź najpierw, czy <code>MAIL_SAFE_RECIPIENT</code> jest uzupełnione. Cicha wysyłka przy włączonym zabezpieczeniu byłaby najgorszym z możliwych zachowań: administrator jest wtedy przekonany, że nic nie wychodzi.</p>
</div>
<h3>Sprawdzenie stanu przed testem</h3>
<p>Ustawienia zmieniają się przez panel, bez restartu aplikacji, więc wartość sprzed kilku minut może już nie obowiązywać. Przed każdym krokiem, który może wysłać wiadomość, sprawdź <strong>faktyczny stan w bazie</strong>:</p>
<pre><code>docker exec webinar-postgres psql -U webinar -d webinar -c \
"SELECT \"key\", value FROM app_settings
WHERE \"key\" LIKE '%SAFE%' OR \"key\"='SMS_ENABLED';"</code></pre>
<div class="note ok">
<p><strong>Zalecenie na czas testów lokalnych:</strong> zostaw oba tryby bezpieczne włączone i ustaw w nich swój własny adres oraz numer. Zobaczysz każdą wiadomość, którą system chciał wysłać, i nikt postronny jej nie dostanie.</p>
</div>
<h3>Co zapisuje dziennik</h3>
<p>Wpis w tabeli <code>notifications</code> zachowuje <strong>prawdziwego odbiorcę</strong>, nie zastępczego. Dziennik dokumentuje zamiar wysyłki, więc po wyłączeniu trybu bezpiecznego nadal wiadomo, kto co miał dostać.</p>
<h3>Przekroczenie limitu to nie błąd</h3>
<p>Wiadomość ponad limit godzinowy dostaje status <strong>oczekującej</strong>, a nie nieudanej. Ma pójść, tylko później: zadanie w tle podejmie ją przy kolejnym przebiegu. Zaproszenie na całą bazę kontaktów to największy jednorazowy ruch w aplikacji, więc to tam bezpiecznik zadziała najczęściej.</p>
<div class="navbtns"><a class="prev" href="#ch4">Wstecz</a><a class="next" href="#ch6">Dalej: webinar testowy</a></div>
</section>
<!-- ============ 6: webinar testowy ============ -->
<section class="chapter" id="ch6">
<div class="kicker">Rozdział 06</div>
<h2>Webinar testowy: komplet danych do wpisania</h2>
<p class="lead">Wszystkie kolejne rozdziały pokazują jeden konkretny webinar: szkolenie o ocenie ryzyka i bezpieczeństwie wody po nowelizacji z 13 marca 2026 r. Ten rozdział zbiera wartości do wpisania, a następne pokazują je na zrzutach z działającej instancji.</p>
<div class="note">
<p><strong>Dlaczego akurat ten temat.</strong> Szkolenie o zmianie przepisów jest typowym zastosowaniem tej platformy: ma ostry termin wynikający z kalendarza ustawowego, precyzyjnie określoną grupę odbiorców i naturalny powód, by zbierać zapytania ofertowe. Sprawdza więc te mechanizmy, które przy sztucznych danych testowych zwykle zostają nietknięte.</p>
</div>
<h3>Zakładka Podstawowe</h3>
<div class="fill">
<div class="fh">Wartości do wpisania</div>
<table>
<tr><td>Tytuł</td><td class="txt">Ocena ryzyka i bezpieczeństwo wody - obowiązki, terminy i sankcje 2026</td></tr>
<tr><td>Adres strony zapisu</td><td>ocena-ryzyka-i-bezpieczenstwo-wody-obowiazki-terminy-i-sankcje-2026</td></tr>
<tr><td>Krótki opis</td><td class="txt">Trzy warstwy oceny ryzyka, kalendarz terminów i katalog kar po nowelizacji z 13 marca 2026 r. Dla wodociągów, zakładów przemysłowych i zarządców obiektów priorytetowych.</td></tr>
</table>
</div>
<div class="note">
<p><strong>Adresu nie wpisuj ręcznie.</strong> Powstaje sam z tytułu: polskie znaki są transliterowane, spacje zamieniane na łączniki, a konflikt z istniejącym adresem rozstrzyga sufiks liczbowy. Powyższa wartość to dokładnie to, co wygeneruje aplikacja.</p>
</div>
<h3>Zakładka Termin</h3>
<div class="fill">
<div class="fh">Wartości do wpisania</div>
<table>
<tr><td>Strefa czasowa</td><td>Europe/Warsaw</td></tr>
<tr><td>Rozpoczęcie</td><td class="txt">dziś, za 10 minut <span style="color:var(--text-muted)">(do testu)</span></td></tr>
<tr><td>Zakończenie</td><td class="txt">dziś, za 70 minut <span style="color:var(--text-muted)">(godzina szkolenia)</span></td></tr>
</table>
</div>
<div class="note warn">
<p><strong>Start za 10 minut to celowy wybór.</strong> Okno wejścia otwiera się 15 minut przed startem, więc pokój jest dostępny natychmiast, a termin pozostaje w przyszłości i nie uruchamia zadania podsumowań. Przy prawdziwym szkoleniu wpisz oczywiście rzeczywistą datę.</p>
</div>
<h3>Zakładka Zapisy</h3>
<div class="fill">
<div class="fh">Wartości do wpisania</div>
<table>
<tr><td>Zapisy otwarte</td><td class="txt">włączone</td></tr>
<tr><td>Limit miejsc</td><td>50</td></tr>
<tr><td>Termin zapisów</td><td class="txt">puste (bez terminu granicznego)</td></tr>
<tr><td>Link do spotkania</td><td class="txt">puste - pokój zakłada LiveKit</td></tr>
</table>
</div>
<h3>Zakładka Treść strony</h3>
<p>Treść buduje program szkolenia z materiału źródłowego. Struktura, która sprawdza się w praktyce: dla kogo, program, podstawa prawna, zastrzeżenie.</p>
<div class="fill">
<div class="fh">Nagłówek: Dla kogo jest to szkolenie</div>
<table>
<tr><td colspan="2" class="txt">Dla przedsiębiorstw wodociągowo-kanalizacyjnych, zakładów przemysłowych z własnym ujęciem wody oraz właścicieli i zarządców obiektów priorytetowych, w których ponad 50 osób dziennie jest narażonych na ryzyko związane z wodą.</td></tr>
</table>
</div>
<div class="fill">
<div class="fh">Nagłówek: Program (lista numerowana)</div>
<table>
<tr><td>1</td><td class="txt">Trzy warstwy oceny ryzyka: obszar zasilania ujęcia, system zaopatrzenia w wodę, wewnętrzny system wodociągowy.</td></tr>
<tr><td>2</td><td class="txt">Kto podlega i od jakiego progu: 10 m3 na dobę, 50 osób, 500 osób, 100 m3 na dobę.</td></tr>
<tr><td>3</td><td class="txt">Kalendarz terminów: 31 grudnia 2026 r. dla obszaru zasilania, 30 czerwca 2028 r. dla pozostałych warstw.</td></tr>
<tr><td>4</td><td class="txt">Katalog sankcji: 10 zł za każdy dzień zwłoki, pasmo do 60 000 zł dla dostawcy wody, do 10 000 zł dla obiektu priorytetowego.</td></tr>
<tr><td>5</td><td class="txt">Kto może wykonać ocenę ryzyka wewnętrznego systemu wodociągowego: wymagania kwalifikacyjne z art. 4l.</td></tr>
<tr><td>6</td><td class="txt">Sześć kwestii, które przemysł musi rozstrzygnąć przed 31 grudnia 2026 r.</td></tr>
<tr><td>7</td><td class="txt">Najczęstsze błędy w opracowaniach obiegowych i jak ich uniknąć w dokumentacji.</td></tr>
</table>
</div>
<div class="fill">
<div class="fh">Nagłówek: Podstawa prawna</div>
<table>
<tr><td colspan="2" class="txt">Materiał opiera się na ustawie z dnia 13 marca 2026 r. (Dz.U. 2026 poz. 605), obowiązującej od 21 maja 2026 r., oraz na rozporządzeniu Ministra Zdrowia z dnia 22 maja 2026 r. (Dz.U. 2026 poz. 748).</td></tr>
<tr><td colspan="2" class="txt">Szkolenie nie stanowi porady prawnej. Kwalifikacja konkretnego podmiotu jako dostawcy wody lub obiektu priorytetowego wymaga potwierdzenia u właściwego organu nadzoru sanitarnego.</td></tr>
</table>
</div>
<div class="note">
<p><strong>Listę numeruje edytor, nie Ty.</strong> Wpisanie własnych numerów na początku pozycji daje na stronie „2. 2.”. Użyj przycisku listy numerowanej i wpisuj samą treść. To zdarzyło się przy przygotowaniu tej dokumentacji i widać było na pierwszym zrzucie.</p>
</div>
<h3>Zakładka Wideo</h3>
<div class="fill">
<div class="fh">Wartości do wpisania</div>
<table>
<tr><td>Dostawca</td><td class="txt">Domyślny z instancji (LiveKit)</td></tr>
<tr><td>Pokój</td><td class="txt">przycisk „Utwórz pokój u dostawcy”</td></tr>
<tr><td>Prelegent: imię i nazwisko</td><td class="txt">Dr inż. Anna Wiśniewska</td></tr>
<tr><td>Prelegent: adres e-mail</td><td>prelegent@example.test</td></tr>
</table>
</div>
<h3>Zakładka Przypomnienia</h3>
<p>Przy szkoleniu z terminem ustawowym harmonogram domyślny instancji jest wystarczający:</p>
<div class="fill">
<div class="fh">Harmonogram domyślny</div>
<table>
<tr><td>24 godziny przed</td><td class="txt">e-mail</td></tr>
<tr><td>1 godzina przed</td><td class="txt">e-mail</td></tr>
<tr><td>15 minut przed</td><td class="txt">SMS (tylko za zgodą i z numerem telefonu)</td></tr>
</table>
</div>
<h3>Dane uczestnika testowego</h3>
<div class="fill">
<div class="fh">Formularz zapisu na stronie publicznej</div>
<table>
<tr><td>Imię</td><td class="txt">Piotr</td></tr>
<tr><td>Nazwisko</td><td class="txt">Zatorski</td></tr>
<tr><td>Adres e-mail</td><td>pzators@go2.pl</td></tr>
<tr><td>Telefon</td><td>453193605</td></tr>
<tr><td>Firma</td><td class="txt">SoftSPM</td></tr>
<tr><td>Zgody</td><td class="txt">wszystkie trzy zaznaczone</td></tr>
</table>
</div>
<div class="note ok">
<p><strong>Użyj własnego adresu i numeru.</strong> Przy włączonym trybie bezpiecznym i tak wszystko trafi na adres zastępczy, ale zapis z prawdziwym adresem pozwala sprawdzić, czy wiadomość dotarła i jak wygląda.</p>
</div>
<h3>Materiał źródłowy</h3>
<p>Treść merytoryczna pochodzi z opracowania „Plany bezpieczeństwa wody i ocena ryzyka - podstawy prawne, terminy i sankcje”, stan prawny na 27 sierpnia 2026 r. Kluczowe liczby, przydatne przy budowaniu zadań i tematów zapytań:</p>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Wartość</th><th>Znaczenie</th></tr></thead>
<tbody>
<tr><td class="mono">31.12.2026</td><td>Pierwsza ocena ryzyka obszaru zasilania ujęcia - najbliższy twardy termin</td></tr>
<tr><td class="mono">30.06.2028</td><td>Pierwsza ocena systemu zaopatrzenia i wewnętrznego systemu wodociągowego</td></tr>
<tr><td class="mono">50 osób</td><td>Próg obiektu priorytetowego: powyżej tej liczby dziennie narażonych na ryzyko</td></tr>
<tr><td class="mono">10 zł / dzień</td><td>Kara za zwłokę w przekazaniu oceny, maksymalnie za 365 dni (3 650 zł)</td></tr>
<tr><td class="mono">do 60 000 zł</td><td>Pasmo kar dla dostawcy wody za naruszenia operacyjne</td></tr>
<tr><td class="mono">do 10 000 zł</td><td>Pasmo kar dla właściciela lub zarządcy obiektu priorytetowego</td></tr>
<tr><td class="mono">12 lat</td><td>Okres przechowywania oceny ryzyka wewnętrznego systemu wodociągowego</td></tr>
</tbody>
</table>
</div>
<div class="navbtns"><a class="prev" href="#ch5">Wstecz</a><a class="next" href="#ch7">Dalej: utworzenie webinaru</a></div>
</section>
<!-- ============ 7 ============ -->
<section class="chapter" id="ch7">
<div class="kicker">Rozdział 07</div>
<h2>Utworzenie webinaru</h2>
<p class="lead">Formularz jest podzielony na zakładki, ale zapis jest jeden. Przechodzenie między zakładkami nie gubi niezapisanych zmian.</p>
<p>Wejdź w <strong>Webinary</strong> i kliknij <strong>Nowy webinar</strong> (adres <code>/webinary/nowy</code>).</p>
<div class="shot tall"><figure><img src="img/06-formularz-podstawowe.png" alt="Zakładka Podstawowe. Adres strony zapisu powstał sam z tytułu, z transliteracją polskich znaków."><figcaption><b>Zakładka Podstawowe.</b> Adres strony zapisu powstał sam z tytułu, z transliteracją polskich znaków.</figcaption></figure></div>
<div class="note">
<p><strong>Trzy zakładki są dostępne dopiero po zapisaniu.</strong> Nagrania i materiały, Przypomnienia oraz Wideo wymagają istniejącego webinaru, bo pliki, reguły i pokój muszą być do czegoś przypisane. Przy tworzeniu zobaczysz w nich komunikat wyjaśniający.</p>
</div>
<div class="tabs">
<div class="tabbar">
<button class="active" data-tab="t1">Podstawowe</button>
<button data-tab="t2">Treść strony</button>
<button data-tab="t3">Termin</button>
<button data-tab="t4">Zapisy</button>
<button data-tab="t5">Przypomnienia</button>
<button data-tab="t6">Pozostałe</button>
</div>
<div class="tabpane active" data-tab="t1">
<h4>Zakładka: Podstawowe</h4>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Pole</th><th>Wymagane</th><th>Opis</th></tr></thead>
<tbody>
<tr><td><strong>Tytuł</strong></td><td><span class="pill alarm">tak</span></td><td>Maksymalnie 255 znaków. Widoczny wszędzie: na stronie zapisu, w mailach, na certyfikacie</td></tr>
<tr><td><strong>Adres strony zapisu</strong></td><td><span class="pill neutral">nie</span></td><td>Końcówka adresu po <code>/w/</code>. Podpowiadana z tytułu, dopóki jej nie dotkniesz. Konflikty rozstrzyga backend sufiksem liczbowym</td></tr>
<tr><td><strong>Krótki opis</strong></td><td><span class="pill neutral">nie</span></td><td>Jedno, dwa zdania bez formatowania. Trafia do zajawki linku w komunikatorach i do treści maili</td></tr>
</tbody>
</table>
</div>
<p>Przy adresie strony są przyciski <strong>Kopiuj</strong> oraz, w trybie edycji, <strong>Otwórz</strong>. Jeśli nie ustawiłeś adresu publicznego instancji, pojawi się tu ostrzeżenie.</p>
<div class="note"><p><strong>Opis to nie treść strony.</strong> To dwie różne rzeczy: opis jest krótką zajawką, a treść strony to długi materiał z edytora. Nie zastępuj jednego drugim.</p></div>
</div>
<div class="tabpane" data-tab="t2">
<h4>Zakładka: Treść strony</h4>
<div class="shot tall"><figure><img src="img/10-tresc-strony.png" alt="Zakładka Treść strony. Edytor wizualny z programem szkolenia. Numerację listy nadaje edytor."><figcaption><b>Zakładka Treść strony.</b> Edytor wizualny z programem szkolenia. Numerację listy nadaje edytor.</figcaption></figure></div>
<p>Edytor wizualny, w którym buduje się zawartość publicznej strony zapisu: nagłówki, listy, obrazki, linki i wyrównanie. Treść pojawia się <strong>nad formularzem zapisu</strong>. Pusta treść oznacza stronę z samym formularzem.</p>
<p>Obrazki wstawia się z biblioteki mediów. Trafiają do MinIO, a do treści wchodzi adres, nigdy dane w base64.</p>
<div class="note"><p><strong>Treść jest czyszczona na backendzie przy każdym zapisie</strong>, według białej listy. Do bazy trafia wyłącznie wersja oczyszczona. Obrazek z obcego serwera jest usuwany, bo ujawniałby adresy IP odwiedzających właścicielowi obcego serwera.</p></div>
</div>
<div class="tabpane" data-tab="t3">
<h4>Zakładka: Termin</h4>
<p>Godziny podaje się <strong>w strefie webinaru</strong>, a nie w strefie przeglądarki. Strefa jest wypisana przy każdym polu.</p>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Pole</th><th>Wymagane</th><th>Opis</th></tr></thead>
<tbody>
<tr><td><strong>Strefa czasowa</strong></td><td><span class="pill neutral">nie</span></td><td>Domyślnie <code>Europe/Warsaw</code></td></tr>
<tr><td><strong>Rozpoczęcie</strong></td><td><span class="pill alarm">tak</span></td><td>Data i godzina startu</td></tr>
<tr><td><strong>Zakończenie</strong></td><td><span class="pill alarm">tak</span></td><td>Musi być późniejsze niż rozpoczęcie. Wyznacza moment startu automatów po webinarze</td></tr>
</tbody>
</table>
</div>
<div class="shot tall"><figure><img src="img/07-formularz-termin.png" alt="Zakładka Termin. Godziny podawane są w strefie webinaru, wypisanej przy każdym polu."><figcaption><b>Zakładka Termin.</b> Godziny podawane są w strefie webinaru, wypisanej przy każdym polu.</figcaption></figure></div>
<div class="note warn"><p><strong>Do testu ustaw termin w przyszłości.</strong> Termin w przeszłości uruchamia zadanie podsumowań i zamyka zapisy. Jeśli chcesz od razu wejść do pokoju, patrz scenariusz w rozdziale 12.</p></div>
</div>
<div class="tabpane" data-tab="t4">
<h4>Zakładka: Zapisy</h4>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Pole</th><th>Opis</th></tr></thead>
<tbody>
<tr><td><strong>Zapisy otwarte</strong></td><td>Przełącznik, domyślnie włączony. To intencja, a nie stan faktyczny</td></tr>
<tr><td><strong>Termin zapisów</strong></td><td>Nie może być późniejszy niż początek webinaru. Puste oznacza brak terminu granicznego</td></tr>
<tr><td><strong>Limit miejsc</strong></td><td>Liczba całkowita, minimum 1. Puste oznacza brak limitu</td></tr>
<tr><td><strong>Link do spotkania</strong></td><td>Wysyłany w mailu potwierdzającym. <strong>Nie jest pokazywany publicznie.</strong> Wypełniany ręcznie w trybie NONE</td></tr>
</tbody>
</table>
</div>
<div class="shot tall"><figure><img src="img/08-formularz-zapisy.png" alt="Zakładka Zapisy. Limit 50 miejsc, zapisy otwarte, bez terminu granicznego."><figcaption><b>Zakładka Zapisy.</b> Limit 50 miejsc, zapisy otwarte, bez terminu granicznego.</figcaption></figure></div>
<div class="note"><p><strong>Przełącznik „zapisy otwarte” to intencja, a nie stan faktyczny.</strong> Aplikacja nigdy nie zmienia go sama. Rzeczywistą dostępność wylicza z intencji, terminu, limitu miejsc i statusu, dzięki czemu po rezygnacji uczestnika zapisy wracają do stanu ustawionego przez Ciebie, zamiast otwierać się same.</p></div>
<div class="note ok"><p>Limit miejsc jest pilnowany <strong>pod blokadą wiersza w bazie</strong>. Kilkadziesiąt równoczesnych zapisów na dziesięć miejsc skończy się dokładnie dziesięcioma wpisami.</p></div>
</div>
<div class="tabpane" data-tab="t5">
<h4>Zakładka: Przypomnienia</h4>
<p>Lista reguł, każda z odstępem przed startem i kanałem. Nowy webinar dostaje harmonogram domyślny z ustawień instancji, a tu można go zmienić dla konkretnego wydarzenia.</p>
<p>Odstęp wybiera się z gotowych wartości albo podaje własny w dniach, godzinach lub minutach. Pod spodem widać skutek zwykłym zdaniem, na przykład „Zostanie dodane przypomnienie 7 dni przed, wysyłane przez e-mail”.</p>
<div class="note"><p><strong>SMS wymaga zgody marketingowej i numeru telefonu.</strong> Uczestnik bez zgody dostanie tylko przypomnienia mailowe. Treść SMS jest celowo bez polskich znaków: ogonki przełączają bramkę na kodowanie, w którym limit spada ze 160 do 70 znaków i jedna wiadomość rozbija się na kilka płatnych części.</p></div>
<div class="note"><p><strong>Przypomnienie spóźnione bardziej niż okno tolerancji</strong> (domyślnie 30 minut) nie idzie w ogóle. Chroni to przed lawiną wysyłek wstecz po restarcie aplikacji.</p></div>
</div>
<div class="tabpane" data-tab="t6">
<h4>Pozostałe zakładki</h4>
<div class="shot tall"><figure><img src="img/13-przypomnienia.png" alt="Zakładka Przypomnienia. Każda reguła to odstęp i kanał, a pod spodem zdanie opisujące skutek."><figcaption><b>Zakładka Przypomnienia.</b> Każda reguła to odstęp i kanał, a pod spodem zdanie opisujące skutek.</figcaption></figure></div>
<p><strong>Wideo</strong> - wybór dostawcy dla tego webinaru, zakładanie pokoju i lista prelegentów. Opisane w rozdziale 8.</p>
<p><strong>Nagrania i materiały</strong> - wgrywanie plików do własnego składu, z ustawieniem widoczności.</p>
<p><strong>Po webinarze</strong> - adresy nagrania i materiałów, gdy pliki leżą poza systemem (dysk sieciowy, YouTube), oraz przycisk <strong>Wyślij powiadomienie o nagraniu</strong>.</p>
<div class="note"><p>Przycisk powiadomienia działa na <strong>zapisanym</strong> adresie nagrania, a nie na treści pola. Inaczej powiadomienie niosłoby link sprzed edycji.</p></div>
</div>
</div>
<h3>Walidacja</h3>
<p>Formularz sprawdza dane przed wysłaniem i pokazuje zbiorczy komunikat „Formularz zawiera błędy”. Reguły:</p>
<ul>
<li>tytuł nie może być pusty,</li>
<li>oba terminy są wymagane, a zakończenie musi być późniejsze niż rozpoczęcie,</li>
<li>termin zapisów nie może być późniejszy niż początek webinaru,</li>
<li>limit miejsc musi być liczbą dodatnią.</li>
</ul>
<div class="note ok">
<p><strong>Błąd walidacji na nieaktywnej zakładce jest widoczny.</strong> Zakładka z błędem dostaje kropkę, a próba zapisu przeskakuje na pierwszą wadliwą. Komunikat nigdy nie wskazuje pola, którego nie widać.</p>
</div>
<div class="shot tall"><figure><img src="img/09-lista-webinarow-draft.png" alt="Lista po zapisaniu. Webinar ma status roboczy i nie jest jeszcze widoczny publicznie." loading="lazy"><figcaption><b>Lista po zapisaniu.</b> Webinar ma status roboczy i nie jest jeszcze widoczny publicznie.</figcaption></figure></div>
<h3>Zapis i status</h3>
<p>Po zapisaniu webinar ma status <span class="pill neutral">DRAFT</span>. Nie jest widoczny publicznie: strona zapisu zwraca 404, a zajawka dla robotów też go nie pokaże. To stan roboczy, w którym można spokojnie dopracować treść.</p>
<div class="navbtns"><a class="prev" href="#ch6">Wstecz</a><a class="next" href="#ch8">Dalej: wideo i prelegenci</a></div>
</section>
<!-- ============ 7 ============ -->
<section class="chapter" id="ch8">
<div class="kicker">Rozdział 08</div>
<h2>Wideo i prelegenci</h2>
<p class="lead">Platforma nie jest przywiązana do jednego dostawcy wideo. Wybór jest świadomą decyzją, bo dostawcy różnią się tym, czy uprawnienia są egzekwowane, czy jedynie deklarowane.</p>
<h3>Co gwarantuje który dostawca</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Dostawca</th><th>Uprawnienia egzekwuje</th><th>Rozróżnia audio i wideo</th><th>Nagrywanie</th></tr></thead>
<tbody>
<tr><td><strong>LIVEKIT</strong></td><td>serwer, na podstawie podpisanego tokenu</td><td><span class="pill ok">tak</span></td><td>Wymaga kontenerów Egress i Redis</td></tr>
<tr><td><strong>DAILY</strong></td><td>serwer</td><td><span class="pill ok">tak</span></td><td>Pobierane od dostawcy po zakończeniu</td></tr>
<tr><td><strong>JITSI</strong> z JWT</td><td>serwer, ale tylko rolę moderatora</td><td><span class="pill warn">nie</span></td><td>Ręczne wgranie pliku</td></tr>
<tr><td><strong>JITSI</strong> bez JWT</td><td><span class="pill alarm">nikt: pokój otwarty</span></td><td><span class="pill warn">nie</span></td><td>Ręczne wgranie pliku</td></tr>
<tr><td><strong>NONE</strong></td><td>nie dotyczy, obraz poza platformą</td><td>nie dotyczy</td><td>Ręczne wgranie pliku</td></tr>
</tbody>
</table>
</div>
<div class="note warn">
<p><strong>Przy Jitsi bez JWT role są umowne.</strong> Pokój jest otwarty dla każdego, kto zna adres, a ukrycie przycisku kamery w interfejsie to jedyne, co dzieli uczestnika od publikacji: wystarczy otworzyć ten sam pokój wprost na domenie Jitsi, żeby to obejść. Panel ostrzega o tym przy wyborze dostawcy. Kto potrzebuje gwarancji, wybiera LiveKit.</p>
</div>
<div class="note">
<p><strong>Tryb NONE jest równoprawny, nie awaryjny.</strong> Instancja bez dostawcy w pełni obsługuje webinary: organizator wkleja własny link do spotkania, a czat, pytania, zadania i banery zostają w platformie. Wielu klientów zostaje przy tym na stałe.</p>
</div>
<h3>Trzy role w pokoju</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Rola</th><th>Uprawnienia</th></tr></thead>
<tbody>
<tr><td>Prelegent</td><td>Publikuje obraz i dźwięk</td></tr>
<tr><td>Uczestnik</td><td>Tylko odbiera, ma czat i pytania</td></tr>
<tr><td>Uczestnik z głosem</td><td>Publikuje wyłącznie dźwięk, po zgodzie moderatora</td></tr>
</tbody>
</table>
</div>
<div class="note"><p><strong>Wideo uczestników jest zawsze wyłączone na poziomie uprawnień w tokenie</strong>, także po podniesieniu do roli z głosem. Przy LiveKit uczestnik nie włączy kamery, choćby podmienił kod strony albo wszedł własnym klientem: serwer odrzuci strumień.</p></div>
<h3>Założenie pokoju</h3>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Otwórz zakładkę Wideo w formularzu webinaru</h4>
<p>Możesz zostawić dostawcę instancji albo wybrać innego tylko dla tego wydarzenia.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Kliknij „Utwórz pokój u dostawcy”</h4>
<p>Konfiguracja jest sprawdzana od razu, żebyś zobaczył brak klucza teraz, a nie dowiedział się o nim od uczestnika minutę przed startem.</p>
<p>W trybie NONE nie ma czego zakładać: zamiast tego wklej własny link w polu „Link do spotkania” w zakładce Zapisy. Panel ostrzeże, jeśli link jest pusty, bo uczestnik nie miałby wtedy gdzie wejść.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Dodaj prelegentów</h4>
<p>W sekcji <strong>Prelegenci</strong> podaj <strong>Imię i nazwisko</strong> oraz <strong>Adres e-mail</strong>, po czym kliknij <strong>Dodaj prelegenta</strong>. Aplikacja wygeneruje indywidualny link wejścia.</p>
<p>Adres służy wyłącznie do odróżnienia prelegentów. <strong>Zaproszenie wysyłasz sam</strong>, kopiując link przyciskiem <strong>Kopiuj link</strong>.</p>
</div></div>
</div>
<div class="shot tall"><figure><img src="img/12-prelegent-formularz.png" alt="Dodawanie prelegenta. Imię, nazwisko i adres. Adres służy tylko do odróżnienia prelegentów." loading="lazy"><figcaption><b>Dodawanie prelegenta.</b> Imię, nazwisko i adres. Adres służy tylko do odróżnienia prelegentów.</figcaption></figure></div>
<div class="shot tall"><figure><img src="img/12b-prelegent-dodany.png" alt="Prelegent dodany. Przycisk „Kopiuj link” podaje indywidualny adres wejścia. Zaproszenie wysyłasz sam." loading="lazy"><figcaption><b>Prelegent dodany.</b> Przycisk „Kopiuj link” podaje indywidualny adres wejścia. Zaproszenie wysyłasz sam.</figcaption></figure></div>
<div class="note">
<p><strong>Prelegent nie jest uczestnikiem.</strong> Nie przechodzi przez formularz zapisu i nie wyraża zgód, więc nie liczy się do liczby zapisanych ani do statystyk zgód. Wchodzi <strong>bez ograniczenia okna czasowego</strong>, bo musi sprawdzić sprzęt przed uczestnikami.</p>
</div>
<div class="shot tall"><figure><img src="img/11-wideo-pokoj.png" alt="Pokój założony u dostawcy. Zielona ramka potwierdza, że uprawnienia egzekwuje serwer na podstawie podpisanego tokenu." loading="lazy"><figcaption><b>Pokój założony u dostawcy.</b> Zielona ramka potwierdza, że uprawnienia egzekwuje serwer na podstawie podpisanego tokenu.</figcaption></figure></div>
<h3>Podniesienie głosu zapisanemu uczestnikowi</h3>
<p>Poza prelegentami można nadać prawo głosu osobie, która zapisała się normalnie. Służy do tego kolumna <strong>Głos</strong> na liście zapisanych. Taki uczestnik dostaje prawo publikacji <strong>wyłącznie dźwięku</strong>: uprawnienia moderatora ma tylko prelegent wchodzący własnym linkiem.</p>
<h3>Dwie kolumny dostawcy, nie jedna</h3>
<p>Webinar pamięta dwie różne rzeczy: <strong>kto ma go obsługiwać</strong> (wybór administratora) i <strong>u kogo pokój faktycznie stoi</strong> (stan zapisany przez aplikację). Rozbieżność między nimi nie jest błędem, tylko sygnałem: „zmieniono dostawcę, pokój wciąż stoi u poprzedniego”. Panel pyta wtedy o potwierdzenie i zamyka stary pokój.</p>
<div class="navbtns"><a class="prev" href="#ch7">Wstecz</a><a class="next" href="#ch9">Dalej: publikacja i zapisy</a></div>
</section>
<!-- ============ 8 ============ -->
<section class="chapter" id="ch9">
<div class="kicker">Rozdział 09</div>
<h2>Publikacja i zapisy</h2>
<p class="lead">Publikacja otwiera stronę zapisu dla świata. Do tego momentu wydarzenie jest niewidoczne.</p>
<h3>Statusy webinaru</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Status</th><th>Znaczenie</th><th>Strona publiczna</th></tr></thead>
<tbody>
<tr><td><span class="pill neutral">DRAFT</span></td><td>Wersja robocza, stan początkowy</td><td>404, także dla robotów</td></tr>
<tr><td><span class="pill ok">PUBLISHED</span></td><td>Opublikowany, zapisy wg reguł dostępności</td><td>Widoczna</td></tr>
<tr><td><span class="pill alarm">CANCELLED</span></td><td>Odwołany, zapisy zamykają się same</td><td>Widoczna z informacją o odwołaniu</td></tr>
<tr><td><span class="pill neutral">FINISHED</span></td><td>Status istnieje w modelu, ale nic go nie ustawia</td><td>-</td></tr>
</tbody>
</table>
</div>
<div class="note">
<p><strong>Podział na wydarzenia aktywne i zakończone idzie po terminie zakończenia, nie po statusie.</strong> Zakładka „Zakończone” na liście pokazuje webinary po ich terminie końca. Filtrowanie po statusie <code>FINISHED</code> dawałoby zawsze pustą listę, bo nic w aplikacji tego statusu nie ustawia.</p>
</div>
<h3>Warunki publikacji</h3>
<p>Przycisk publikacji jest w wierszu listy webinarów, widoczny dopóki wydarzenie nie jest opublikowane ani odwołane. Publikacja przechodzi tylko wtedy, gdy webinar ma:</p>
<ul>
<li>niepusty tytuł,</li>
<li>niepusty adres strony zapisu,</li>
<li>oba terminy uzupełnione,</li>
<li>status inny niż odwołany - <strong>webinaru odwołanego nie da się opublikować ponownie</strong>.</li>
</ul>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Sprawdź stronę przed publikacją</h4>
<p>Ikona oka w wierszu listy otwiera kartę webinaru <code>/webinary/{id}</code> <strong>w nowej karcie przeglądarki</strong>. Zakładka „Treść strony” pokazuje dokładnie to, co zobaczy uczestnik.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Opublikuj</h4>
<p>Status zmienia się na <code>PUBLISHED</code>, strona zapisu staje się dostępna, a w wierszu listy pojawia się ikona <strong>Prowadź spotkanie</strong>.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Otwórz stronę zapisu</h4>
<p>Adres to <code>http://localhost:8081/w/{slug}</code>. Ta sama strona, którą dostanie uczestnik.</p>
</div></div>
</div>
<div class="shot tall"><figure><img src="img/14-lista-opublikowany.png" alt="Po publikacji. Status zmienia się na opublikowany, a w wierszu pojawia się ikona prowadzenia spotkania." loading="lazy"><figcaption><b>Po publikacji.</b> Status zmienia się na opublikowany, a w wierszu pojawia się ikona prowadzenia spotkania.</figcaption></figure></div>
<h3>Karta webinaru</h3>
<p>Karta pod <code>/webinary/{id}</code> zbiera wszystko, co należy do jednego wydarzenia, w ośmiu zakładkach:</p>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Zakładka</th><th>Zawartość</th></tr></thead>
<tbody>
<tr><td>Treść strony</td><td>Podgląd tego, co widzi uczestnik</td></tr>
<tr><td>Zaproszenia</td><td>Wysyłka do kontaktów z bazy, z podglądem liczby odbiorców</td></tr>
<tr><td>Zapisani</td><td>Lista uczestników z licznikiem</td></tr>
<tr><td>Banery</td><td>Reklamy tego webinaru</td></tr>
<tr><td>Certyfikaty</td><td>Wydawanie i unieważnianie dokumentów</td></tr>
<tr><td>Statystyki</td><td>Obecność, aktywność, skuteczność</td></tr>
<tr><td>Zapytania ofertowe</td><td>Kontakty handlowe z tego wydarzenia, z licznikiem</td></tr>
<tr><td>Historia czatu</td><td>Archiwum rozmowy, z wiadomościami usuniętymi przez moderatora</td></tr>
</tbody>
</table>
</div>
<div class="shot tall"><figure><img src="img/18-karta-webinaru.png" alt="Karta webinaru. Osiem zakładek zbiera wszystko, co należy do jednego wydarzenia." loading="lazy"><figcaption><b>Karta webinaru.</b> Osiem zakładek zbiera wszystko, co należy do jednego wydarzenia.</figcaption></figure></div>
<h3>Zapis uczestnika</h3>
<p>Uczestnik podaje dane, zaznacza zgody i wysyła formularz. Po zapisie dostaje mail z potwierdzeniem, załącznikiem kalendarza <code>invite.ics</code> i linkiem do własnego zapisu pod <code>/zapis/{token}</code>, gdzie może sprawdzić szczegóły albo zrezygnować.</p>
<div class="shot"><figure><img src="img/15-strona-publiczna.png" alt="Publiczna strona zapisu. Treść nad formularzem, licznik wolnych miejsc, formularz zawsze na dole." loading="lazy"><figcaption><b>Publiczna strona zapisu.</b> Treść nad formularzem, licznik wolnych miejsc, formularz zawsze na dole.</figcaption></figure></div>
<div class="grid2">
<div class="card">
<h3>Zgody</h3>
<p><strong>Żadna zgoda nie jest zaznaczona domyślnie</strong>, także ta wymagana. Zaznaczanie zgody za użytkownika jest niedopuszczalne.</p>
<p>Zgody są <strong>dopisywane, nigdy nadpisywane</strong>. Ponowny zapis tworzy nowe wpisy, a stare zostają jako ślad rozliczalny. Zapisywana jest także odmowa.</p>
</div>
<div class="card">
<h3>Brak enumeracji uczestników</h3>
<p>Przy próbie zapisu na już zapisany adres API zwraca <strong>tę samą odpowiedź co przy sukcesie</strong> i wysyła mail informujący, że zapis już istnieje.</p>
<p>Gdyby zwracało błąd, dałoby się sprawdzać, kto jest zapisany na dane szkolenie.</p>
</div>
</div>
<div class="note">
<p><strong>Link do spotkania nie trafia do publicznego API.</strong> Idzie wyłącznie do maila potwierdzającego i do podglądu własnego zapisu po tokenie, i tylko wtedy, gdy zapis nie jest anulowany.</p>
</div>
<div class="shot"><figure><img src="img/16-formularz-zapisu-wypelniony.png" alt="Formularz zapisu wypełniony. Żadna zgoda nie jest zaznaczona domyślnie, także wymagana." loading="lazy"><figcaption><b>Formularz zapisu wypełniony.</b> Żadna zgoda nie jest zaznaczona domyślnie, także wymagana.</figcaption></figure></div>
<div class="shot tall"><figure><img src="img/17-potwierdzenie-zapisu.png" alt="Potwierdzenie zapisu. Ten sam ekran zobaczy osoba, która zapisuje się drugi raz tym samym adresem." loading="lazy"><figcaption><b>Potwierdzenie zapisu.</b> Ten sam ekran zobaczy osoba, która zapisuje się drugi raz tym samym adresem.</figcaption></figure></div>
<p>Rezygnacja z udziału jest możliwa ze strony własnego zapisu. Ponowny zapis po rezygnacji jest dozwolony, a zakolejkowane przypomnienia są przy rezygnacji kasowane.</p>
<h3>Zaproszenia z bazy kontaktów</h3>
<p>Poza publiczną stroną zapisu można zaprosić kontakty z bazy. Zakładka <strong>Zaproszenia</strong> pozwala wybrać grupy, zobaczyć <strong>podgląd liczby odbiorców po odfiltrowaniu</strong> wypisanych, osób bez zgody i już zaproszonych, a potem wysłać.</p>
<div class="note">
<p><strong>Jedno zaproszenie na osobę i wydarzenie.</strong> Powtórna wysyłka po dołożeniu kontaktów do grupy nie zaprosi drugi raz tych, którzy już dostali wiadomość. Warunki są sprawdzane ponownie tuż przed wysyłką: kontakt mógł się wypisać między zakolejkowaniem a wysłaniem.</p>
</div>
<div class="note warn">
<p>Każda wiadomość marketingowa ma stopkę z linkiem wypisania. Maile transakcyjne - potwierdzenie, przypomnienie, podsumowanie - jej nie mają i nie podlegają wypisaniu: to obsługa zamówionej usługi, a nie marketing.</p>
</div>
<div class="navbtns"><a class="prev" href="#ch8">Wstecz</a><a class="next" href="#ch10">Dalej: przeprowadzenie webinaru</a></div>
</section>
<!-- ============ 9 ============ -->
<section class="chapter" id="ch10">
<div class="kicker">Rozdział 10</div>
<h2>Przeprowadzenie webinaru</h2>
<p class="lead">W dniu wydarzenia pracujesz na dwóch ekranach: sali spotkania i panelu prowadzenia. Uczestnik ma jeden ekran, na którym ma wszystko.</p>
<h3>Okno czasowe pokoju</h3>
<div class="note warn">
<p>Uczestnik wchodzi <strong>od 15 minut przed startem do 30 minut po zakończeniu</strong> (wartości domyślne, do zmiany w ustawieniach). Poza tym oknem dostaje czytelny komunikat z odliczaniem „Pokój otworzy się za...”, a strona odświeży się sama, gdy nadejdzie pora. <strong>Prelegent wchodzi bez tego ograniczenia.</strong></p>
<p>To najczęstsza przyczyna zgłoszenia „link nie działa” przy poprawnie skonfigurowanej instancji.</p>
</div>
<h3>Ekran uczestnika</h3>
<p>Adres <code>/spotkanie/{token}</code> jest wspólny dla uczestnika i prelegenta: strona sama rozpoznaje, kto wchodzi, więc organizator nie musi pamiętać, który link komu wysłał.</p>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Przedpokój</h4>
<p>Najpierw uczestnik widzi tytuł, godzinę w strefie webinaru i ramkę <strong>„Wchodzisz jako”</strong> z imieniem i nazwiskiem z zapisu. Prelegent ma tam dodatkowo oznaczenie roli.</p>
<p>Link otwiera się bez logowania, więc bez tego ekranu osoba z przekazanym linkiem zaczynałaby występować pod cudzym nazwiskiem, nie mając szansy tego zauważyć.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Wejście na salę</h4>
<p>Uczestnik klika <strong>Dołącz do webinaru</strong>, prelegent <strong>Wejdź i rozpocznij</strong>. Pod przyciskiem jest informacja, że mikrofon i kamera są wyłączone, a o głos można poprosić w trakcie.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Sala</h4>
<p>Obraz spotkania i panel interakcji obok. Nad panelem jest <strong>pasek akcji</strong>, widoczny zawsze, niezależnie od otwartej zakładki: schowany w zakładce byłby bezużyteczny dokładnie wtedy, gdy jest potrzebny.</p>
</div></div>
</div>
<div class="shot tall"><figure><img src="img/20-przedpokoj.png" alt="Przedpokój. Ramka „Wchodzisz jako” pokazuje imię i nazwisko z zapisu, do potwierdzenia przed wejściem." loading="lazy"><figcaption><b>Przedpokój.</b> Ramka „Wchodzisz jako” pokazuje imię i nazwisko z zapisu, do potwierdzenia przed wejściem.</figcaption></figure></div>
<div class="shot tall"><figure><img src="img/21-sala-uczestnika.png" alt="Sala uczestnika. Obraz spotkania, pasek akcji nad panelem i cztery zakładki interakcji." loading="lazy"><figcaption><b>Sala uczestnika.</b> Obraz spotkania, pasek akcji nad panelem i cztery zakładki interakcji.</figcaption></figure></div>
<h3>Pasek akcji uczestnika</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Przycisk</th><th>Zachowanie</th></tr></thead>
<tbody>
<tr><td><strong>Chcę zabrać głos</strong></td><td>Zmienia się w „Czekasz na głos” z możliwością wycofania, a po przyznaniu w „Masz głos, mikrofon jest włączony” z opcją oddania. Przy otwartym mikrofonie zamiast przycisku jest napis „Możesz mówić bez zgłaszania się”</td></tr>
<tr><td><strong>Proszę o kontakt</strong></td><td>Zapytanie bez pisania treści. Uczestnik w trakcie webinaru często nie ma pomysłu, co napisać, a wymuszanie treści kończy się tym, że nie wysyła nic</td></tr>
<tr><td><strong>Interesuje mnie oferta</strong></td><td>Pełny formularz zapytania, w oknie, bez opuszczania sali</td></tr>
</tbody>
</table>
</div>
<div class="note">
<p><strong>Po wysłaniu przycisk pokazuje potwierdzenie</strong>, a kolejne zapytanie wymaga świadomego kliknięcia „Wyślij kolejne”. Bez widocznej zmiany uczestnik kliknąłby trzeci raz, a handlowiec dostałby trzy zgłoszenia od tej samej osoby.</p>
<p>Zgoda na kontakt handlowy jest wymagana tak samo w obu trybach: uproszczenie dotyczy tego, ile uczestnik pisze, a nie tego, na co się zgadza.</p>
</div>
<h3>Cztery zakładki panelu uczestnika</h3>
<div class="tblwrap">
<table class="tbl">
<thead><tr><th>Zakładka</th><th>Zawartość</th></tr></thead>
<tbody>
<tr><td><strong>Czat</strong></td><td>Rozmowa na żywo. Autor pokazany jako imię i pierwsza litera nazwiska, nigdy adres e-mail</td></tr>
<tr><td><strong>Pytania</strong></td><td>Pytania do prowadzącego, z głosowaniem. Jedna osoba głosuje raz</td></tr>
<tr><td><strong>Zadania</strong></td><td>Zapisy na zadania ogłoszone podczas webinaru, z limitem miejsc</td></tr>
<tr><td><strong>Oferta</strong></td><td>Zapytanie ofertowe</td></tr>
</tbody>
</table>
</div>
<div class="note">
<p><strong>Licznik przy zakładce pokazuje nowe pozycje od ostatniego zajrzenia</strong>, a nie łączną liczbę wpisów. Łączna liczba przy czacie rośnie cały czas i po kwadransie nie niesie żadnej informacji.</p>
</div>
<div class="shot tall"><figure><img src="img/23-uczestnik-czat.png" alt="Uczestnik po wysłaniu wiadomości i zgłoszeniu się po głos. Przycisk zmienia się w „Czekasz na głos”." loading="lazy"><figcaption><b>Uczestnik po wysłaniu wiadomości i zgłoszeniu się po głos.</b> Przycisk zmienia się w „Czekasz na głos”.</figcaption></figure></div>
<div class="note ok">
<p><strong>W trybie NONE panel interakcji zostaje.</strong> Zamiast osadzonego obrazu jest przycisk otwierający spotkanie w nowej karcie, ale czat, pytania, zadania i banery działają dalej u nas. To jest sedno tego trybu: bez dostawcy platforma nadal ma sens.</p>
</div>
<h3>Panel prowadzenia</h3>
<p>Adres <code>/webinary/{id}/prowadzenie</code>, wejście ikoną w wierszu listy. Nagłówek pokazuje stan połączenia i ma przełącznik sygnału dźwiękowego. Poniżej cztery kolumny, każda to niezależny strumień pracy:</p>
<div class="grid2">
<div class="card">
<h3>Zgłoszenia do głosu</h3>
<p>Osoby z przyznanym głosem na górze („Mówi od...”), pod nimi oczekujący, <strong>najstarsi na górze</strong>, z informacją, ile już czekają. Akcje: <strong>Daj głos</strong>, <strong>Odrzuć</strong>, <strong>Odbierz</strong>.</p>
<p>Tu też jest przełącznik <strong>otwartego mikrofonu</strong>.</p>
</div>
<div class="card">
<h3>Czat na żywo</h3>
<p>Podgląd rozmowy, wypowiedzi prelegenta wyróżnione. Ikona kosza usuwa wiadomość, zostawiając ślad dla moderatora zamiast kasować wpis.</p>
</div>
<div class="card">
<h3>Kolejka pytań</h3>
<p>Najpierw sekcja oczekujących na zatwierdzenie: <strong>Zatwierdź</strong> albo <strong>Odrzuć</strong>. Opublikowane są sortowane: przypięte pierwsze, potem po liczbie głosów.</p>
<p>Do każdego można <strong>przypiąć</strong>, <strong>ukryć</strong> albo wpisać <strong>odpowiedź widoczną dla uczestników</strong>.</p>
</div>
<div class="card">
<h3>Zadania</h3>
<p>Lista zadań z licznikiem zapisanych i limitem, z możliwością zamknięcia i otwarcia zapisów. Na dole formularz: tytuł, opcjonalny limit miejsc i przycisk <strong>Ogłoś</strong>.</p>
</div>
</div>
<div class="shot"><figure><img src="img/24-panel-z-interakcjami.png" alt="Panel prowadzenia w trakcie spotkania. Zgłoszenie w kolejce głosu i wiadomość na czacie: autor jako imię i pierwsza litera nazwiska." loading="lazy"><figcaption><b>Panel prowadzenia w trakcie spotkania.</b> Zgłoszenie w kolejce głosu i wiadomość na czacie: autor jako imię i pierwsza litera nazwiska.</figcaption></figure></div>
<div class="note">
<p><strong>Pytanie oczekujące nie jest widoczne dla uczestników.</strong> To jedyna bariera przed pokazaniem niezatwierdzonej treści całej sali, więc kolejka wymaga świadomego zatwierdzenia każdej pozycji.</p>
</div>
<p>Gdy przyjdzie nowe zapytanie ofertowe, na górze panelu pojawia się baner z odnośnikiem do listy zapytań. Jest też sygnał dźwiękowy, domyślnie włączony: to zgłoszenie, którego prowadzący najbardziej nie chce przegapić, a w trakcie mówienia nie patrzy na ekran.</p>
<div class="note">
<p><strong>Przeglądarki blokują dźwięk do pierwszej interakcji użytkownika.</strong> Kliknij cokolwiek w panelu, żeby sygnał zaczął działać. Wybór jest zapamiętywany w przeglądarce.</p>
</div>
<h3>Tryb otwartego mikrofonu</h3>
<p>Przełącznik pozwala mówić każdemu bez proszenia o głos. Domyślnie wyłączony i to jest świadome: otwarty mikrofon na webinarze dla stu osób oznacza sto otwartych mikrofonów naraz. Ma sens przy warsztacie dla kilkunastu osób.</p>
<div class="note">
<p><strong>Włączenie pyta o potwierdzenie, wyłączenie nie</strong>: otwarcie mikrofonów całej sali jest słyszalne natychmiast i trudno to cofnąć po fakcie.</p>
<p><strong>Włączenie nie kasuje kolejki.</strong> Przy włączonym trybie jest ona nieaktywna, a nie nieaktualna, więc po wyłączeniu osoby czekające od kwadransa nie tracą miejsca.</p>
<p><strong>Wyłączenie nie odbiera głosu</strong> osobom, którym przyznano go indywidualnie: ich uprawnienie wynika z roli w zapisie, a nie z trybu.</p>
</div>
<p>Wideo pozostaje wyłączone także w tym trybie: otwarty mikrofon nie włącza nikomu kamery.</p>
<h3>Obecność</h3>
<p>Obecność liczy się z <strong>sesji</strong>, a nie z flagi. Ludzie wchodzą i wychodzą, tracą połączenie, wracają z telefonu; dopiero suma sesji mówi, ile ktoś naprawdę był obecny, i pozwala narysować wykres obecności w czasie.</p>
<p>Przeglądarka wysyła sygnał co minutę istniejącym już połączeniem, więc działa to <strong>także w trybie NONE</strong>, gdzie żadnego dostawcy nie ma. Sesje porzucone - zamknięta karta bez wylogowania - domyka zadanie w tle, przyjmując jako koniec <strong>ostatni sygnał</strong>, a nie chwilę zamknięcia.</p>
<div class="navbtns"><a class="prev" href="#ch9">Wstecz</a><a class="next" href="#ch11">Dalej: po webinarze</a></div>
</section>
<!-- ============ 10 ============ -->
<section class="chapter" id="ch11">
<div class="kicker">Rozdział 11</div>
<h2>Po webinarze</h2>
<p class="lead">Część dzieje się sama, część wymaga decyzji organizatora.</p>
<h3>Co dzieje się automatycznie</h3>
<p>Godzinę po zakończeniu (wartość <code>SUMMARY_DELAY_MINUTES</code>) zadanie w tle kolejkuje maile: uczestnikom podziękowanie z listą zadań i linkami, prowadzącemu zestawienie.</p>
<div class="note">
<p><strong>Podsumowanie dostaje tylko uczestnik ze statusem potwierdzonym.</strong> Osoba, która zrezygnowała, nie dostanie maila po wydarzeniu.</p>
<p><strong>Podsumowania nie da się wysłać drugi raz</strong> i to jest celowe: uczestnik nie ma dostać dwa razy tej samej wiadomości. Gwarancję „dokładnie raz” daje ograniczenie w bazie, a nie samo sprawdzenie w kodzie.</p>
</div>
<p>Zestawienie dla prowadzącego zawiera dane kontaktowe, więc idzie wyłącznie na adresy wpisane w ustawieniach, nigdy na adres wzięty z formularza.</p>
<h3>Nagranie</h3>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Wgraj plik albo poczekaj na pobranie</h4>
<p>Przy LiveKit z Egress nagranie trafia prosto do MinIO. Przy pozostałych dostawcach wgrywasz plik ręcznie w zakładce <strong>Nagrania i materiały</strong>. Dopuszczone są MP4 i WEBM, a typ jest rozpoznawany z zawartości pliku, nie z rozszerzenia.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Ustaw widoczność</h4>
<p>Trzy poziomy: prywatne, dla zapisanych i publiczne. Domyślnie <strong>dla zapisanych</strong>, a nie publicznie, bo nagranie zawiera wypowiedzi uczestników i pytania z imionami: udostępnienie go całemu internetowi musi być świadomą decyzją.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Powiadom uczestników</h4>
<p>Przycisk w zakładce „Po webinarze”. To osobna wysyłka, bo montaż zwykle trwa dłużej niż godzina, a podsumowania nie da się powtórzyć. Każdy odbiorca dostaje ją dokładnie raz.</p>
</div></div>
</div>
<div class="note">
<p><strong>Pliki idą przez backend, nie prosto z MinIO.</strong> Dzięki temu każde pobranie przechodzi przez kontrolę widoczności. Token musi dotyczyć <strong>tego</strong> webinaru, a po rezygnacji przestaje działać natychmiast.</p>
<p><strong>Własny skład ma pierwszeństwo przed adresem zewnętrznym.</strong> Mail najpierw szuka nagrania w składzie, a dopiero potem sięga po wpisany adres.</p>
</div>
<h3>Certyfikaty</h3>
<p>Zakładka <strong>Certyfikaty</strong> na karcie webinaru. Można wydać dokument pojedynczej osobie albo wszystkim naraz.</p>
<div class="note">
<p><strong>Certyfikat to dokument, nie widok danych.</strong> Plik powstaje raz i leży w składzie: wydany dziś ma wyglądać tak samo za rok, niezależnie od tego, czy ktoś zmienił tytuł webinaru, szablon albo logo.</p>
<p><strong>Automat wydaje wyłącznie uczestnikom z odnotowaną obecnością.</strong> Certyfikat dla osoby bez obecności jest decyzją organizatora, a nie automatu; panel pyta o nią wprost.</p>
<p><strong>Unieważnienie nie kasuje dokumentu.</strong> Weryfikacja pokaże wtedy „nieważny”, a nie „nie znaleziono”, bo dokument mógł już trafić do teczki uczestnika albo do pracodawcy. Unieważniony certyfikat znika za to ze strony uczestnika i z maila.</p>
</div>
<p>Weryfikacja publiczna pod <code>/certyfikat/{numer}</code> zwraca minimum: numer, tytuł, datę, imię z pierwszą literą nazwiska i informację o ważności. Endpoint jest otwarty dla całego internetu, więc nigdy nie pokazuje adresu e-mail, pełnego nazwiska ani linku do pliku.</p>
<div class="note ok">
<p><strong>Dostęp do pliku certyfikatu jest węższy niż do nagrania.</strong> Nagranie jest wspólne dla webinaru, więc wystarcza token dowolnego uczestnika. Certyfikat to dokument jednej osoby, z jej imieniem i nazwiskiem, więc token musi wskazywać właśnie ją.</p>
</div>
<div class="shot tall"><figure><img src="img/26-zapytania.png" alt="Zapytania ofertowe. Ekran zbiorczy dla handlowca, ten sam komponent co zakładka na karcie webinaru." loading="lazy"><figcaption><b>Zapytania ofertowe.</b> Ekran zbiorczy dla handlowca, ten sam komponent co zakładka na karcie webinaru.</figcaption></figure></div>
<h3>Statystyki</h3>
<p>Zakładka <strong>Statystyki</strong> pokazuje liczby zapisów i obecnych, wykres obecności w czasie, aktywność na czacie oraz skuteczność banerów i zaproszeń. Wszystko liczone w bazie, zapytaniami agregującymi.</p>
<p>Wykres obecności kończy się na chwili bieżącej, gdy webinar jeszcze trwa: rysowanie do przyszłego końca dawałoby długą linię zer. Skuteczność zaproszeń liczy się <strong>po wysłanych</strong>, nie po zakolejkowanych: zaproszenie, które nie wyszło, nie miało szansy przynieść zapisu.</p>
<div class="shot"><figure><img src="img/25-statystyki.png" alt="Statystyki webinaru. Liczby zapisów i obecnych, wykres obecności w czasie, aktywność i skuteczność." loading="lazy"><figcaption><b>Statystyki webinaru.</b> Liczby zapisów i obecnych, wykres obecności w czasie, aktywność i skuteczność.</figcaption></figure></div>
<h3>Eksport listy zapisanych</h3>
<p>Na liście zapisanych jest eksport CSV. Obejmuje <strong>wszystkich</strong> zapisanych, a nie bieżącą stronę, i zawiera czas obecności oraz wersję i datę zgody. Flaga obecności jest przy okazji przeliczana, żeby liczba na ekranie nie rozjechała się z kolumną w pliku.</p>
<div class="shot tall"><figure><img src="img/19-zapisani.png" alt="Lista zapisanych. Kolumny statusu, głosu i obecności; eksport CSV obejmuje wszystkich, nie tylko bieżącą stronę." loading="lazy"><figcaption><b>Lista zapisanych.</b> Kolumny statusu, głosu i obecności; eksport CSV obejmuje wszystkich, nie tylko bieżącą stronę.</figcaption></figure></div>
<h3>Kopiowanie webinaru</h3>
<p>Przy szkoleniach cyklicznych najczęstszą czynnością jest skopiowanie istniejącego wydarzenia ikoną kopiowania w wierszu listy.</p>
<div class="grid2">
<div class="card">
<h3>Co przechodzi</h3>
<p>Treść strony, treść zaproszenia, ustawienia certyfikatów, reguły przypomnień, tematy zapytań i materiały. Reguła wyłączona świadomie zostaje wyłączona także w kopii.</p>
</div>
<div class="card">
<h3>Czego nie ma w kopii</h3>
<p>Zapisy, obecność, interakcje, nagrania, certyfikaty i pokój u dostawcy. Skopiowanie czegokolwiek z tej listy oznaczałoby uczestników zapisanych na wydarzenie, które się nie odbyło, albo certyfikaty z cudzego szkolenia.</p>
</div>
</div>
<div class="note"><p><strong>Kopia dostaje termin zastępczy</strong>: pełną godzinę za tydzień, o długości przepisanej z oryginału. Data jest w przyszłości, a nie w przeszłości, żeby przy przypadkowej publikacji zapisy się otworzyły, a nie zamknęły natychmiast. Panel mówi o tym wprost i od razu przechodzi do edycji kopii.</p></div>
<h3>Usunięcie webinaru</h3>
<div class="note warn">
<p>Panel najpierw sprawdza, <strong>ile przepadnie</strong>: liczbę zapisów i wydanych certyfikatów, i dopiero z tymi liczbami pyta o potwierdzenie. Przy webinarze ze setką zapisów odpowiedź jest zupełnie inna niż przy pustym szkicu.</p>
<p>Pytanie podpowiada <strong>odwołanie jako łagodniejszą alternatywę</strong>, bo w większości przypadków chodzi o zamknięcie zapisów, a nie skasowanie danych. <strong>Pliki w MinIO zostają</strong>: utrata pliku jest gorsza niż zajęte miejsce.</p>
</div>
<div class="navbtns"><a class="prev" href="#ch10">Wstecz</a><a class="next" href="#ch12">Dalej: scenariusz testowy</a></div>
</section>
<!-- ============ 11 ============ -->
<section class="chapter" id="ch12">
<div class="kicker">Rozdział 12</div>
<h2>Scenariusz testowy na localhost</h2>
<p class="lead">Gotowa ścieżka, która przechodzi przez wszystkie mechanizmy w jednym posiedzeniu, w około 30 minut.</p>
<div class="note">
<p><strong>Wartości do wpisania są w rozdziale 6.</strong> Ten rozdział podaje kolejność czynności, a nie treść pól: komplet danych webinaru o ocenie ryzyka i bezpieczeństwie wody zebrany jest w jednym miejscu, żeby dało się go przepisać bez przewijania instrukcji.</p>
</div>
<div class="note ok">
<p><strong>Zanim zaczniesz:</strong> potwierdź, że tryby bezpieczne są włączone i mają ustawiony Twój adres oraz numer (rozdział 5). Cała wysyłka trafi wtedy do Ciebie.</p>
</div>
<h3>Przygotowanie</h3>
<ul class="checklist">
<li>Kontenery działają: <code>docker compose ps</code> pokazuje usługi jako <code>Up</code></li>
<li><code>curl http://localhost:8080/api/health</code> zwraca <code>UP</code></li>
<li>Profil wideo uruchomiony, jeśli testujesz spotkanie: <code>docker compose --profile livekit up -d</code></li>
<li>Adres publiczny instancji ustawiony na <code>http://localhost:8081</code></li>
<li>Poczta skonfigurowana i test wysyłki przechodzi</li>
<li>Tryb bezpieczny włączony, adres i numer zastępczy uzupełnione</li>
</ul>
<h3>Przebieg</h3>
<div class="steps">
<div class="step"><div class="sbody">
<h4>Utwórz webinar z terminem tuż przed nosem</h4>
<p>Ustaw <strong>rozpoczęcie za 10 minut</strong>, zakończenie za 70 minut. Dzięki oknu wejścia otwartemu 15 minut przed startem pokój będzie dostępny <strong>od razu</strong>, a jednocześnie termin jest w przyszłości, więc nie ruszy zadanie podsumowań.</p>
<div class="note warn"><p>Nie ustawiaj terminu w przeszłości. Dane testowe z terminem wstecz wystarczą, żeby scheduler potraktował webinar jako zakończony i rozesłał podsumowania.</p></div>
</div></div>
<div class="step"><div class="sbody">
<h4>Dodaj treść strony i limit miejsc</h4>
<p>Wpisz kilka akapitów w edytorze i ustaw limit, na przykład 5 miejsc. Pozwoli to później sprawdzić licznik wolnych miejsc. Zapisz webinar.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Załóż pokój i dodaj siebie jako prelegenta</h4>
<p>Wejdź w edycję, zakładka <strong>Wideo</strong>: „Utwórz pokój u dostawcy”, potem dodaj prelegenta i <strong>skopiuj jego link</strong>. Zapisz go, będzie potrzebny za chwilę.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Opublikuj i zapisz się jako uczestnik</h4>
<p>Opublikuj webinar, otwórz <code>http://localhost:8081/w/{slug}</code> <strong>w oknie prywatnym</strong> i wypełnij formularz swoim adresem. Zaznacz zgodę wymaganą i marketingową.</p>
<p>Sprawdź skrzynkę: powinien przyjść mail z potwierdzeniem, załącznikiem kalendarza i linkiem do własnego zapisu.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Sprawdź brak enumeracji</h4>
<p>Wyślij formularz drugi raz tym samym adresem. Ekran potwierdzenia ma być <strong>identyczny</strong> jak za pierwszym razem, a na skrzynkę przyjdzie wiadomość o istniejącym już zapisie.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Wejdź na salę z dwóch stron</h4>
<p>W zwykłym oknie otwórz <strong>link prelegenta</strong>, w oknie prywatnym <strong>link uczestnika</strong> z maila. W obu przypadkach potwierdź tożsamość w przedpokoju i wejdź na salę.</p>
<p>Sprawdź, czy prelegent publikuje obraz i dźwięk, a uczestnik tylko odbiera.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Otwórz panel prowadzenia i przetestuj interakcje</h4>
<p>W trzeciej karcie otwórz <code>/webinary/{id}/prowadzenie</code>. Z okna uczestnika kolejno:</p>
<ul>
<li>napisz wiadomość na czacie i sprawdź, czy widać ją w panelu,</li>
<li>zadaj pytanie, zatwierdź je w panelu i sprawdź, czy pojawia się uczestnikom,</li>
<li>zgłoś się po głos i przyznaj go z panelu, sprawdzając, czy mikrofon się otwiera,</li>
<li>ogłoś zadanie z panelu i zapisz się na nie z okna uczestnika,</li>
<li>wyślij zapytanie ofertowe i sprawdź sygnał dźwiękowy oraz wpis w <code>/zapytania</code>.</li>
</ul>
</div></div>
<div class="step"><div class="sbody">
<h4>Sprawdź otwarty mikrofon</h4>
<p>Włącz przełącznik w kolumnie zgłoszeń i potwierdź. W oknie uczestnika przycisk proszenia o głos ma zniknąć na rzecz napisu o możliwości mówienia. Wyłącz go z powrotem i sprawdź, czy kolejka wróciła.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Sprawdź obecność i statystyki</h4>
<p>Zostaw okno uczestnika otwarte na kilka minut, potem wejdź na kartę webinaru, zakładka <strong>Statystyki</strong>. Powinna pokazać obecność i wykres w czasie.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Zakończ i sprawdź automaty</h4>
<p>Aby nie czekać godziny, skróć webinar: w edycji ustaw zakończenie na czas miniony i odczekaj do następnego przebiegu zadania. Sprawdź, czy przyszło podsumowanie i czy w dzienniku powiadomień jest wpis.</p>
<div class="note"><p>Pamiętaj, że w trybie bezpiecznym wszystkie te wiadomości przyjdą na Twój adres zastępczy, także te zaadresowane formalnie do kogoś innego.</p></div>
</div></div>
<div class="step"><div class="sbody">
<h4>Wgraj nagranie i wydaj certyfikat</h4>
<p>W zakładce „Nagrania i materiały” wgraj dowolny plik MP4, ustaw widoczność „dla zapisanych” i wyślij powiadomienie o nagraniu. Następnie w zakładce Certyfikaty wydaj dokument i sprawdź go pod <code>/certyfikat/{numer}</code>.</p>
<p>Otwórz PDF i sprawdź, czy <strong>polskie znaki wyświetlają się poprawnie</strong>.</p>
</div></div>
<div class="step"><div class="sbody">
<h4>Skopiuj webinar</h4>
<p>Na liście kliknij ikonę kopiowania. Sprawdź, czy kopia ma treść i przypomnienia, a <strong>nie ma</strong> zapisów ani certyfikatów, oraz czy dostała termin zastępczy za tydzień.</p>
</div></div>
</div>
<h3>Lista kontrolna testu</h3>
<ul class="checklist">
<li>Strona zapisu widoczna dopiero po publikacji</li>
<li>Mail potwierdzający z załącznikiem kalendarza</li>
<li>Powtórny zapis daje identyczny ekran</li>
<li>Przedpokój pokazuje imię z zapisu do potwierdzenia</li>
<li>Prelegent publikuje obraz, uczestnik tylko odbiera</li>
<li>Czat, pytania i głosowanie działają w obie strony</li>
<li>Kolejka głosu otwiera i zamyka mikrofon</li>
<li>Otwarty mikrofon nie kasuje kolejki</li>
<li>Zadanie ogłoszone w panelu widoczne u uczestnika</li>
<li>Zapytanie ofertowe trafia na listę i daje sygnał dźwiękowy</li>
<li>Obecność i wykres w statystykach</li>
<li>Podsumowanie po zakończeniu, dokładnie raz</li>
<li>Nagranie dostępne dla zapisanych, niedostępne bez tokenu</li>
<li>Certyfikat z poprawnymi polskimi znakami i działającą weryfikacją</li>
<li>Kopia bez zapisów, z terminem zastępczym</li>
</ul>
<div class="navbtns"><a class="prev" href="#ch11">Wstecz</a><a class="next" href="#ch13">Dalej: rozwiązywanie problemów</a></div>
</section>
<!-- ============ 12 ============ -->
<section class="chapter" id="ch13">
<div class="kicker">Rozdział 13</div>
<h2>Rozwiązywanie problemów</h2>
<p class="lead">Katalog objawów, które przy tej architekturze pojawiają się najczęściej, wraz z rzeczywistą przyczyną.</p>
<div class="acc">
<button type="button">Uczestnicy widzą się na liście, ale nie słyszą i nie widzą obrazu</button>
<div class="body">
<p>Najczęstsza przyczyna przy poprawnie skonfigurowanym serwerze: <strong>zablokowany zakres portów UDP</strong>. To główna droga ruchu audio i wideo. Sygnalizacja idzie osobno, więc pokój wygląda na działający.</p>
<p>Przy uruchomieniu lokalnym druga typowa przyczyna to brak <code>LIVEKIT_NODE_IP=127.0.0.1</code>: serwer rozgłasza wtedy publiczny adres łącza, pod który przeglądarka na tej samej maszynie się nie połączy.</p>
</div>
</div>
<div class="acc">
<button type="button">Sala wygląda dobrze, ale nic nie płynie po podniesieniu biblioteki klienta</button>
<div class="body">
<p>Wersja serwera musi nadążać za biblioteką kliencką. Nowszy klient łączy się ścieżką, której starszy serwer nie zna: uczestnik wchodzi i widzi listę obecnych, ale media nie płyną. Podnosząc bibliotekę, sprawdź wersję obrazu serwera.</p>
</div>
</div>
<div class="acc">
<button type="button">Maile nie wychodzą, choć SMTP jest skonfigurowany</button>
<div class="body">
<p>Sprawdź, czy tryb bezpieczny jest włączony <strong>bez ustawionego adresu zastępczego</strong>. Wtedy wysyłka jest celowo wstrzymywana, zamiast trafić do prawdziwego odbiorcy.</p>
<p>Druga możliwość to bezpiecznik ilościowy: wiadomość dostaje wtedy status oczekującej i pójdzie później. To nie jest błąd.</p>
<p>Stan sprawdzisz zapytaniem z rozdziału 5. Nie polegaj na tym, co było ustawione wcześniej: ustawienia zmieniają się bez restartu.</p>
</div>
</div>
<div class="acc">
<button type="button">Panel nagle pokazuje wylogowanie albo „wymagane uwierzytelnienie”</button>
<div class="body">
<p>Objaw jest mylący i rzadko dotyczy logowania. Zwykle oznacza błąd 500 po stronie backendu, który front tłumaczy jako wygasłą sesję.</p>
<p>Typowa przyczyna to encja opuszczająca warstwę serwisu: pole doczytywane leniwie poza transakcją. Szukanie tego w warstwie logowania nie ma sensu, zajrzyj do logów backendu.</p>
</div>
</div>
<div class="acc">
<button type="button">Uczestnik dostaje komunikat, że pokój jest zamknięty</button>
<div class="body">
<p>To nie błąd, tylko okno czasowe: wejście otwiera się 15 minut przed startem i zamyka 30 minut po zakończeniu. Uczestnik widzi odliczanie, a strona odświeży się sama.</p>
<p>Jeśli chcesz wejść wcześniej do testów, użyj linku prelegenta: on nie podlega temu ograniczeniu.</p>
</div>
</div>
<div class="acc">
<button type="button">Nie mogę opublikować webinaru</button>
<div class="body">
<p>Publikacja wymaga tytułu, adresu strony zapisu i obu terminów. Sprawdź też status: <strong>webinaru odwołanego nie da się opublikować ponownie</strong>.</p>
</div>
</div>
<div class="acc">
<button type="button">Lista przestaje działać, choć filtr jest pusty</button>
<div class="body">
<p>Typowy objaw braku rzutowania typu przy parametrze opcjonalnym w zapytaniu. Baza nie potrafi wywnioskować typu pustej wartości, więc <strong>lista psuje się dokładnie wtedy, gdy filtr nie jest używany</strong>, czyli w najzwyklejszym użyciu.</p>
</div>
</div>
<div class="acc">
<button type="button">Polskie znaki w certyfikacie PDF zamieniają się w krzyżyki</button>
<div class="body">
<p>Czcionka musi być osadzona w pliku i rejestrowana w kodzie. Deklaracja czcionki w arkuszu stylów szablonu <strong>przykrywa</strong> tę rejestrację: generator próbuje pobrać plik, nie znajduje go i podstawia czcionkę zastępczą.</p>
<p>Certyfikat z błędem w nazwisku jest bezużyteczny, więc to jest błąd blokujący, a nie kosmetyczny.</p>
</div>
</div>
<div class="acc">
<button type="button">Nagrywanie nie startuje mimo działających spotkań</button>
<div class="body">
<p>Sam profil wideo bez usług nagrywania nie nagrywa. Serwer przyjmuje zadanie i przekazuje je przez pośrednika; bez niego zadanie nie ma odbiorcy i kończy się przekroczeniem czasu, przy w pełni sprawnym serwerze.</p>
<p>Uruchom cały profil: <code>docker compose --profile livekit up -d</code>.</p>
<div class="note warn"><p><strong>Stan weryfikacji:</strong> nagrywanie automatyczne nie jest potwierdzone od początku do końca w środowisku lokalnym. Ręczne wgranie pliku działa i pozostaje drogą awaryjną.</p></div>
</div>
</div>
<div class="acc">
<button type="button">Przypomnienia w ogóle nie idą</button>
<div class="body">
<p>Sprawdź, czy webinar ma reguły przypomnień i czy są włączone. Przypomnienie spóźnione bardziej niż okno tolerancji nie idzie w ogóle, co chroni przed lawiną wysyłek wstecz po restarcie.</p>
<p>SMS wymaga dodatkowo zgody marketingowej i numeru telefonu u uczestnika.</p>
</div>
</div>
<div class="acc">
<button type="button">Uczestnik nie widzi pytania, które zadał</button>
<div class="body">
<p>Pytanie oczekujące na zatwierdzenie nie jest widoczne publicznie. Zatwierdź je w kolejce pytań w panelu prowadzenia.</p>
</div>
</div>
<h3>Przydatne polecenia</h3>
<pre><code><span class="c"># logi backendu na żywo</span>
docker compose logs -f backend
<span class="c"># stan kontenerów</span>
docker compose ps
<span class="c"># restart pojedynczej usługi</span>
docker compose restart backend
<span class="c"># zatrzymanie całości, dane w wolumenach zostają</span>
docker compose down
<span class="c"># dziennik powiadomień: co i do kogo poszło</span>
docker exec webinar-postgres psql -U webinar -d webinar -c \
"SELECT created_at, template, recipient, status FROM notifications
ORDER BY created_at DESC LIMIT 20;"</code></pre>
<div class="note alarm">
<p><strong>Nigdy nie kasuj wolumenów.</strong> Ani przez <code>docker compose down -v</code>, ani przez <code>docker volume rm</code>, ani przez <code>docker system prune -a --volumes</code>. Tabela ustawień i konto administratora są nietykalne: odtworzenie wymaga ręcznego wpisania od nowa danych SMTP, kluczy bramki SMS i kluczy dostawcy wideo, których nie ma nigdzie indziej.</p>
<p>Czyszczenie danych testowych ogranicza się do tabel operacyjnych: webinarów i wszystkiego, co od nich zależy, oraz powiadomień i plików multimedialnych.</p>
</div>
<div class="footer-sig">
Platforma webinarowa - instrukcja uruchomienia i przeprowadzenia webinaru. Środowisko lokalne (localhost). Dokument opisuje stan aplikacji na dzień 30 sierpnia 2026.
</div>
<div class="navbtns"><a class="prev" href="#ch12">Wstecz</a><span></span></div>
</section>
</main>
</div>
<script>
(function(){
var chapters = Array.prototype.slice.call(document.querySelectorAll('.chapter'));
var links = Array.prototype.slice.call(document.querySelectorAll('.toc a'));
var bar = document.getElementById('progressBar');
var sidebar = document.getElementById('sidebar');
var backdrop = document.getElementById('navBackdrop');
var navToggle = document.getElementById('navToggle');
var mbStep = document.getElementById('mbStep');
function pad(n){ return (n < 10 ? '0' : '') + n; }
function setNav(open){
sidebar.classList.toggle('open', open);
backdrop.classList.toggle('show', open);
backdrop.hidden = !open;
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
document.body.style.overflow = open ? 'hidden' : '';
}
function closeNav(){ setNav(false); }
function show(id, scroll){
var target = document.getElementById(id) || chapters[0];
chapters.forEach(function(c){ c.classList.toggle('visible', c === target); });
links.forEach(function(a){ a.classList.toggle('active', a.getAttribute('href') === '#' + target.id); });
var idx = chapters.indexOf(target);
bar.style.width = ((idx + 1) / chapters.length * 100) + '%';
if (mbStep) mbStep.textContent = pad(idx + 1) + ' / ' + pad(chapters.length);
closeNav();
if (scroll !== false) window.scrollTo({top:0, behavior:'auto'});
}
function fromHash(){ show((location.hash || '#ch1').substring(1)); }
window.addEventListener('hashchange', fromHash);
fromHash();
navToggle.addEventListener('click', function(){ setNav(!sidebar.classList.contains('open')); });
backdrop.addEventListener('click', closeNav);
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeNav(); });
window.addEventListener('resize', function(){ if (window.innerWidth > 900) closeNav(); });
document.getElementById('themeToggle').addEventListener('click', function(){
var root = document.documentElement;
var cur = root.getAttribute('data-theme');
if (!cur) cur = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
});
document.getElementById('printAll').addEventListener('click', function(){ window.print(); });
document.addEventListener('click', function(e){
var tb = e.target.closest('.tabbar button');
if (tb){
var tabs = tb.closest('.tabs');
tabs.querySelectorAll('.tabbar button').forEach(function(b){ b.classList.toggle('active', b === tb); });
var pane = tabs.querySelector('.tabpane[data-tab="' + tb.getAttribute('data-tab') + '"]');
tabs.querySelectorAll('.tabpane').forEach(function(p){ p.classList.toggle('active', p === pane); });
return;
}
var ab = e.target.closest('.acc > button');
if (ab){ ab.parentElement.classList.toggle('open'); }
});
})();
</script>
</body>
</html>