Files
boc/aamos-admin-upgrade/web/onboarding.html
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

1647 lines
56 KiB
HTML

<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AAMOS — Onboarding</title>
<style>
:root {
--bg: #F2F2F7;
--bg-secondary: #FFFFFF;
--bg-tertiary: #F2F2F7;
--card-bg: #FFFFFF;
--text-primary: #000000;
--text-secondary: #3C3C43;
--text-tertiary: #8E8E93;
--separator: #C6C6C8;
--primary: #007AFF;
--primary-light: rgba(0,122,255,0.12);
--success: #34C759;
--success-light: rgba(52,199,89,0.12);
--warning: #FF9500;
--warning-light: rgba(255,149,0,0.12);
--danger: #FF3B30;
--danger-light: rgba(255,59,48,0.12);
--shadow: 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08);
--radius: 12px;
--radius-sm: 8px;
--transition: 0.3s ease;
--font: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'Helvetica Neue', sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000;
--bg-secondary: #1C1C1E;
--bg-tertiary: #2C2C2E;
--card-bg: #1C1C1E;
--text-primary: #FFFFFF;
--text-secondary: rgba(235,235,245,0.6);
--text-tertiary: rgba(235,235,245,0.3);
--separator: rgba(84,84,88,0.6);
--primary-light: rgba(0,122,255,0.2);
--success-light: rgba(52,199,89,0.2);
--warning-light: rgba(255,149,0,0.2);
--danger-light: rgba(255,59,48,0.2);
--shadow: 0 2px 8px rgba(0,0,0,0.35), 0 1px 2px rgba(0,0,0,0.2);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.3);
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: var(--font);
background: var(--bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ─────────────────────────────────────────── */
.app { display: flex; height: 100vh; overflow: hidden; }
/* ── Sidebar ────────────────────────────────────────── */
.sidebar {
width: 272px;
min-width: 272px;
background: var(--card-bg);
border-right: 1px solid var(--separator);
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar-header {
padding: 32px 24px 24px;
border-bottom: 1px solid var(--separator);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 42px;
height: 42px;
background: var(--primary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
box-shadow: 0 4px 14px rgba(0,122,255,0.4);
flex-shrink: 0;
}
.logo-name { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.logo-sub { font-size: 12px; color: var(--text-tertiary); margin-top: 1px; }
.sidebar-nav {
flex: 1;
padding: 20px 12px;
overflow-y: auto;
}
.nav-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--text-tertiary);
padding: 0 12px;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
margin-bottom: 2px;
transition: background var(--transition);
cursor: default;
}
.nav-item.active { background: var(--primary-light); }
.nav-item.disabled { opacity: 0.38; pointer-events: none; }
.nav-badge {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
border: 1.5px solid var(--separator);
background: var(--bg-tertiary);
color: var(--text-tertiary);
transition: all var(--transition);
}
.nav-item.active .nav-badge {
background: var(--primary);
border-color: var(--primary);
color: #fff;
box-shadow: 0 2px 8px rgba(0,122,255,0.4);
}
.nav-item.completed .nav-badge {
background: var(--success);
border-color: var(--success);
color: #fff;
font-size: 15px;
}
.nav-info { flex: 1; min-width: 0; }
.nav-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.nav-item.active .nav-title { color: var(--primary); font-weight: 600; }
.nav-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-footer {
padding: 16px 24px 24px;
border-top: 1px solid var(--separator);
}
.progress-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.progress-text { font-size: 12px; color: var(--text-secondary); font-weight: 500; }
.progress-pct { font-size: 12px; color: var(--primary); font-weight: 700; }
.progress-track {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #34AAFF);
border-radius: 2px;
transition: width 0.55s cubic-bezier(0.4,0,0.2,1);
}
/* ── Main ───────────────────────────────────────────── */
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.main-header {
padding: 36px 48px 0;
flex-shrink: 0;
}
.step-eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 6px;
}
.step-title {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.8px;
color: var(--text-primary);
margin-bottom: 6px;
}
.step-subtitle {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.55;
margin-bottom: 28px;
}
/* ── Steps ──────────────────────────────────────────── */
.steps-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.step {
position: absolute;
inset: 0;
padding: 0 48px 24px;
overflow-y: auto;
opacity: 0;
transform: translateX(48px);
transition: opacity var(--transition), transform var(--transition);
pointer-events: none;
}
.step.active {
opacity: 1;
transform: translateX(0);
pointer-events: all;
}
.step.going-back {
transform: translateX(-48px);
}
/* ── Cards / form sections ──────────────────────────── */
.card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(0,0,0,0.05);
}
@media (prefers-color-scheme: dark) {
.card { border-color: rgba(255,255,255,0.07); }
}
.card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
margin-bottom: 16px;
}
/* ── Info grid ──────────────────────────────────────── */
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.info-tile {
background: var(--card-bg);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow);
border: 1px solid rgba(0,0,0,0.05);
}
@media (prefers-color-scheme: dark) {
.info-tile { border-color: rgba(255,255,255,0.07); }
}
.tile-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.tile-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.tile-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 3px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
margin-right: 6px;
box-shadow: 0 0 6px rgba(52,199,89,0.7);
animation: blink 2.4s ease-in-out infinite;
}
@keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0.45 } }
/* ── Form elements ──────────────────────────────────── */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="url"],
select,
textarea {
width: 100%;
padding: 10px 14px;
font-size: 15px;
font-family: var(--font);
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1.5px solid var(--separator);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
-webkit-appearance: none;
appearance: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238E8E93' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 13px center;
padding-right: 34px;
cursor: pointer;
}
.hint { font-size: 12px; color: var(--text-tertiary); margin-top: 5px; }
.error { font-size: 12px; color: var(--danger); margin-top: 5px; display: none; }
.form-group.has-error input,
.form-group.has-error select { border-color: var(--danger); box-shadow: 0 0 0 3px var(--danger-light); }
.form-group.has-error .error { display: block; }
/* ── Password strength ──────────────────────────────── */
.pw-strength { margin-top: 8px; }
.strength-bar {
display: flex;
gap: 4px;
margin-bottom: 5px;
}
.strength-seg {
flex: 1;
height: 3px;
border-radius: 2px;
background: var(--separator);
transition: background var(--transition);
}
.strength-text { font-size: 12px; color: var(--text-tertiary); }
/* ── Module list ────────────────────────────────────── */
.module-list { display: flex; flex-direction: column; }
.module-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--separator);
}
.module-row:last-child { border-bottom: none; }
.mod-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.mod-info { flex: 1; }
.mod-name { font-size: 15px; font-weight: 500; color: var(--text-primary); }
.mod-desc { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
.badge {
font-size: 11px;
font-weight: 600;
padding: 3px 9px;
border-radius: 20px;
letter-spacing: 0.2px;
flex-shrink: 0;
}
.badge-req { background: var(--primary-light); color: var(--primary); }
.badge-rec { background: var(--success-light); color: var(--success); }
.badge-opt { background: var(--bg-tertiary); color: var(--text-tertiary); }
/* ── Toggle ─────────────────────────────────────────── */
.toggle-wrap {
position: relative;
width: 51px;
height: 31px;
flex-shrink: 0;
}
.toggle-wrap input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute;
inset: 0;
background: var(--separator);
border-radius: 20px;
cursor: pointer;
transition: background var(--transition);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
background: #fff;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.22);
transition: transform var(--transition);
pointer-events: none;
}
.toggle-wrap input:checked ~ .toggle-track { background: var(--success); }
.toggle-wrap input:checked ~ .toggle-thumb { transform: translateX(20px); }
.toggle-wrap input:disabled ~ .toggle-track { opacity: 0.55; }
/* ── Integrations ───────────────────────────────────── */
.int-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.int-card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
border: 1.5px solid var(--separator);
cursor: pointer;
transition: border-color var(--transition), box-shadow var(--transition), transform var(--transition);
user-select: none;
}
.int-card:hover { border-color: var(--primary); transform: translateY(-2px); box-shadow: var(--shadow-lg); }
.int-card.connected { border-color: var(--success); }
.int-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.int-logo {
width: 38px;
height: 38px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.int-status {
font-size: 11px;
font-weight: 600;
padding: 3px 9px;
border-radius: 20px;
}
.int-status.on { background: var(--success-light); color: var(--success); }
.int-status.off { background: var(--bg-tertiary); color: var(--text-tertiary); }
.int-name { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.int-desc { font-size: 12px; color: var(--text-tertiary); margin-top: 3px; }
.int-form {
display: none;
opacity: 0;
transition: opacity var(--transition);
}
/* ── Done step ──────────────────────────────────────── */
.done-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 55vh;
text-align: center;
padding: 32px 48px;
}
.done-circle {
width: 96px;
height: 96px;
background: var(--success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 52px;
color: #fff;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(52,199,89,0.45);
animation: pop-in 0.65s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes pop-in {
from { transform: scale(0) rotate(-45deg); opacity: 0; }
to { transform: scale(1) rotate(0deg); opacity: 1; }
}
.done-title { font-size: 34px; font-weight: 700; letter-spacing: -1px; margin-bottom: 12px; }
.done-subtitle { font-size: 16px; color: var(--text-secondary); line-height: 1.6; max-width: 480px; margin-bottom: 32px; }
.summary-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-bottom: 36px;
}
.pill {
display: flex;
align-items: center;
gap: 6px;
background: var(--card-bg);
border: 1px solid var(--separator);
border-radius: 20px;
padding: 5px 14px;
font-size: 13px;
color: var(--text-secondary);
box-shadow: var(--shadow);
}
.pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
flex-shrink: 0;
}
/* ── Buttons ────────────────────────────────────────── */
.footer {
padding: 20px 48px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--separator);
flex-shrink: 0;
}
.footer-info { font-size: 13px; color: var(--text-tertiary); }
.footer-btns {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 500;
font-family: var(--font);
cursor: pointer;
border: none;
outline: none;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: var(--primary);
color: #fff;
box-shadow: 0 2px 8px rgba(0,122,255,0.32);
}
.btn-primary:hover:not(:disabled) {
background: #0066D6;
box-shadow: 0 4px 16px rgba(0,122,255,0.42);
transform: translateY(-1px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--separator);
}
.btn-secondary:hover:not(:disabled) { background: var(--separator); }
.btn-ghost {
background: transparent;
color: var(--primary);
}
.btn-ghost:hover:not(:disabled) { background: var(--primary-light); }
.btn-success {
background: var(--success);
color: #fff;
padding: 13px 36px;
font-size: 17px;
font-weight: 600;
border-radius: var(--radius);
box-shadow: 0 3px 12px rgba(52,199,89,0.35);
}
.btn-success:hover:not(:disabled) {
background: #29A84A;
box-shadow: 0 6px 20px rgba(52,199,89,0.45);
transform: translateY(-1px);
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
.spinner {
width: 17px;
height: 17px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.65s linear infinite;
display: none;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn.loading .spinner { display: block; }
.btn.loading .btn-text { opacity: 0.75; }
/* ── Toast ──────────────────────────────────────────── */
#toast {
position: fixed;
bottom: 28px;
right: 28px;
background: var(--card-bg);
border: 1px solid var(--separator);
border-radius: var(--radius);
padding: 13px 18px;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-primary);
transform: translateY(80px);
opacity: 0;
transition: all var(--transition);
z-index: 9000;
max-width: 340px;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast-ico { font-size: 18px; flex-shrink: 0; }
/* ── Confetti ───────────────────────────────────────── */
#confetti {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
}
/* ── Scrollbar ──────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--separator); border-radius: 3px; }
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 760px) {
.sidebar { display: none; }
.main-header { padding: 24px 24px 0; }
.step { padding: 0 24px 24px; }
.footer { padding: 16px 24px; }
.info-grid { grid-template-columns: 1fr 1fr; }
.int-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<canvas id="confetti"></canvas>
<div class="app">
<!-- ── Sidebar ─────────────────────────────────────── -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon"></div>
<div>
<div class="logo-name">AAMOS</div>
<div class="logo-sub">Admin Console</div>
</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label" style="margin-bottom:12px;">Setup Wizard</div>
<div class="nav-item active" id="nav-1">
<div class="nav-badge">1</div>
<div class="nav-info">
<div class="nav-title">Systeminformation</div>
<div class="nav-desc">Miljö & hostname</div>
</div>
</div>
<div class="nav-item disabled" id="nav-2">
<div class="nav-badge">2</div>
<div class="nav-info">
<div class="nav-title">Admin-användare</div>
<div class="nav-desc">Uppgifter & behörighet</div>
</div>
</div>
<div class="nav-item disabled" id="nav-3">
<div class="nav-badge">3</div>
<div class="nav-info">
<div class="nav-title">Aktivera moduler</div>
<div class="nav-desc">Tjänster & funktioner</div>
</div>
</div>
<div class="nav-item disabled" id="nav-4">
<div class="nav-badge">4</div>
<div class="nav-info">
<div class="nav-title">Integrationer</div>
<div class="nav-desc">Externa tjänster</div>
</div>
</div>
<div class="nav-item disabled" id="nav-5">
<div class="nav-badge">5</div>
<div class="nav-info">
<div class="nav-title">Klar!</div>
<div class="nav-desc">Systemet är redo</div>
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="progress-row">
<span class="progress-text">Konfigurationsstatus</span>
<span class="progress-pct" id="pct">0%</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="prog" style="width:0%"></div>
</div>
</div>
</aside>
<!-- ── Main ──────────────────────────────────────────── -->
<main class="main">
<div class="main-header">
<div class="step-eyebrow" id="eyebrow">Steg 1 av 5</div>
<div class="step-title" id="title">Systeminformation</div>
<div class="step-subtitle" id="subtitle">Bekräfta och konfigurera grundläggande miljöinställningar för AAMOS.</div>
</div>
<div class="steps-wrap">
<!-- ── Step 1: System Info ── -->
<div class="step active" id="step-1">
<div class="info-grid">
<div class="info-tile">
<div class="tile-label">Status</div>
<div class="tile-value"><span class="status-dot"></span>Online</div>
<div class="tile-sub">All systems operational</div>
</div>
<div class="info-tile">
<div class="tile-label">Hostname</div>
<div class="tile-value" id="sys-hostname">aamos-prod-01</div>
<div class="tile-sub" id="sys-ip">10.0.1.42</div>
</div>
<div class="info-tile">
<div class="tile-label">Platform</div>
<div class="tile-value">Linux</div>
<div class="tile-sub">Ubuntu 22.04 LTS · x86_64</div>
</div>
<div class="info-tile">
<div class="tile-label">CPU</div>
<div class="tile-value">8 vCPU</div>
<div class="tile-sub">Intel Xeon · 3.2 GHz</div>
</div>
<div class="info-tile">
<div class="tile-label">Minne</div>
<div class="tile-value">32 GB</div>
<div class="tile-sub">DDR4 · 2.1 GB används</div>
</div>
<div class="info-tile">
<div class="tile-label">Lagring</div>
<div class="tile-value">500 GB</div>
<div class="tile-sub">NVMe SSD · 48 GB används</div>
</div>
</div>
<div class="card">
<div class="card-label">Miljökonfiguration</div>
<div class="form-row">
<div class="form-group">
<label for="s-name">Systemnamn</label>
<input type="text" id="s-name" value="AAMOS Production" placeholder="t.ex. AAMOS Production">
</div>
<div class="form-group">
<label for="s-env">Miljö</label>
<select id="s-env">
<option value="production" selected>Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="s-tz">Tidszon</label>
<select id="s-tz">
<option value="Europe/Stockholm" selected>Europe/Stockholm (CET+1)</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York (EST)</option>
<option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
</select>
</div>
<div class="form-group">
<label for="s-lang">Språk</label>
<select id="s-lang">
<option value="sv" selected>Svenska</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label for="s-domain">Domän / URL</label>
<input type="text" id="s-domain" placeholder="t.ex. aamos.example.com">
<div class="hint">Används för SSL-certifikat och externa webhooks.</div>
</div>
</div>
</div>
<!-- ── Step 2: Admin User ── -->
<div class="step" id="step-2">
<div class="card">
<div class="card-label">Kontoinformation</div>
<div class="form-row">
<div class="form-group" id="fg-fn">
<label for="a-fn">Förnamn</label>
<input type="text" id="a-fn" placeholder="Anna" autocomplete="given-name">
<div class="error">Obligatoriskt fält</div>
</div>
<div class="form-group" id="fg-ln">
<label for="a-ln">Efternamn</label>
<input type="text" id="a-ln" placeholder="Andersson" autocomplete="family-name">
<div class="error">Obligatoriskt fält</div>
</div>
</div>
<div class="form-group" id="fg-email">
<label for="a-email">E-postadress</label>
<input type="email" id="a-email" placeholder="admin@example.com" autocomplete="email">
<div class="error">Ange en giltig e-postadress</div>
</div>
<div class="form-group" id="fg-user" style="margin-bottom:0">
<label for="a-user">Användarnamn</label>
<input type="text" id="a-user" placeholder="admin" autocomplete="username">
<div class="hint">Bokstäver, siffror och bindestreck.</div>
<div class="error">Ogiltigt användarnamn</div>
</div>
</div>
<div class="card">
<div class="card-label">Lösenord</div>
<div class="form-group" id="fg-pw">
<label for="a-pw">Lösenord</label>
<input type="password" id="a-pw" placeholder="Minst 12 tecken" autocomplete="new-password">
<div class="pw-strength">
<div class="strength-bar">
<div class="strength-seg" id="ps1"></div>
<div class="strength-seg" id="ps2"></div>
<div class="strength-seg" id="ps3"></div>
<div class="strength-seg" id="ps4"></div>
</div>
<div class="strength-text" id="sw">Ange ett lösenord</div>
</div>
<div class="error">Lösenordet måste vara minst 12 tecken</div>
</div>
<div class="form-group" id="fg-conf" style="margin-bottom:0">
<label for="a-conf">Bekräfta lösenord</label>
<input type="password" id="a-conf" placeholder="Upprepa lösenordet" autocomplete="new-password">
<div class="error">Lösenorden matchar inte</div>
</div>
</div>
<div class="card">
<div class="card-label">Roll & behörighet</div>
<div class="form-group" style="margin-bottom:0">
<label for="a-role">Roll</label>
<select id="a-role">
<option value="superadmin" selected>Super Admin — Full systemåtkomst</option>
<option value="admin">Admin — Begränsad systemåtkomst</option>
<option value="operator">Operator — Läs + driftsåtgärder</option>
</select>
</div>
</div>
</div>
<!-- ── Step 3: Modules ── -->
<div class="step" id="step-3">
<div class="card">
<div class="card-label">Kärnmoduler</div>
<div class="module-list">
<div class="module-row">
<div class="mod-icon" style="background:rgba(0,122,255,.12);color:#007AFF">📊</div>
<div class="mod-info">
<div class="mod-name">Monitoring &amp; Metrics</div>
<div class="mod-desc">Realtidsövervakning, alerting och dashboards</div>
</div>
<span class="badge badge-req">Obligatorisk</span>
<label class="toggle-wrap">
<input type="checkbox" checked disabled>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(52,199,89,.12);color:#34C759">🔒</div>
<div class="mod-info">
<div class="mod-name">Security &amp; Audit</div>
<div class="mod-desc">Intrångsdetektering, audit logs och säkerhetsscanning</div>
</div>
<span class="badge badge-req">Obligatorisk</span>
<label class="toggle-wrap">
<input type="checkbox" checked disabled>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(255,149,0,.12);color:#FF9500">📋</div>
<div class="mod-info">
<div class="mod-name">Structured Logging</div>
<div class="mod-desc">Centraliserad log-aggregation med fulltextsökning</div>
</div>
<span class="badge badge-rec">Rekommenderad</span>
<label class="toggle-wrap">
<input type="checkbox" checked id="m-log">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
</div>
</div>
<div class="card">
<div class="card-label">Tillvalsmoduler</div>
<div class="module-list">
<div class="module-row">
<div class="mod-icon" style="background:rgba(175,82,222,.12);color:#AF52DE">🤖</div>
<div class="mod-info">
<div class="mod-name">AI/ML Pipeline</div>
<div class="mod-desc">Modellträning, inferenstjänster och dataflöden</div>
</div>
<span class="badge badge-opt">Tillval</span>
<label class="toggle-wrap">
<input type="checkbox" id="m-ai">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(255,59,48,.12);color:#FF3B30">💾</div>
<div class="mod-info">
<div class="mod-name">Backup &amp; Recovery</div>
<div class="mod-desc">Schemalagda backuper med point-in-time recovery</div>
</div>
<span class="badge badge-rec">Rekommenderad</span>
<label class="toggle-wrap">
<input type="checkbox" checked id="m-bak">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(0,199,190,.12);color:#00C7BE">🌐</div>
<div class="mod-info">
<div class="mod-name">API Gateway</div>
<div class="mod-desc">Rate limiting, JWT-autentisering och smart routing</div>
</div>
<span class="badge badge-opt">Tillval</span>
<label class="toggle-wrap">
<input type="checkbox" id="m-apigw">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(90,200,250,.12);color:#5AC8FA">📡</div>
<div class="mod-info">
<div class="mod-name">Message Queue</div>
<div class="mod-desc">Asynkron meddelandehantering via AMQP / Kafka</div>
</div>
<span class="badge badge-opt">Tillval</span>
<label class="toggle-wrap">
<input type="checkbox" id="m-mq">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
<div class="module-row">
<div class="mod-icon" style="background:rgba(255,204,0,.12);color:#D4A000">📈</div>
<div class="mod-info">
<div class="mod-name">Analytics &amp; BI</div>
<div class="mod-desc">Avancerad dataanalys, pivottabeller och rapporter</div>
</div>
<span class="badge badge-opt">Tillval</span>
<label class="toggle-wrap">
<input type="checkbox" id="m-bi">
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</label>
</div>
</div>
</div>
</div>
<!-- ── Step 4: Integrations ── -->
<div class="step" id="step-4">
<div class="int-grid">
<div class="int-card" id="ic-slack">
<div class="int-card-top">
<div class="int-logo" style="background:rgba(74,21,75,.08)">💬</div>
<span class="int-status off" id="is-slack">Tillval</span>
</div>
<div class="int-name">Slack</div>
<div class="int-desc">Alerts och notifikationer direkt till Slack-kanaler</div>
</div>
<div class="int-card" id="ic-github">
<div class="int-card-top">
<div class="int-logo" style="background:rgba(36,41,47,.08)">🐙</div>
<span class="int-status off" id="is-github">Tillval</span>
</div>
<div class="int-name">GitHub</div>
<div class="int-desc">CI/CD-integration och repo-webhooks</div>
</div>
<div class="int-card" id="ic-pd">
<div class="int-card-top">
<div class="int-logo" style="background:rgba(6,150,109,.08)">🚨</div>
<span class="int-status off" id="is-pd">Tillval</span>
</div>
<div class="int-name">PagerDuty</div>
<div class="int-desc">On-call alerting och incident management</div>
</div>
<div class="int-card" id="ic-smtp">
<div class="int-card-top">
<div class="int-logo" style="background:rgba(0,122,255,.08)">✉️</div>
<span class="int-status off" id="is-smtp">Tillval</span>
</div>
<div class="int-name">E-post (SMTP)</div>
<div class="int-desc">Systemmeddelanden och lösenordsåterställning</div>
</div>
</div>
<div class="int-form card" id="if-slack">
<div class="card-label">Slack-konfiguration</div>
<div class="form-group">
<label for="sl-wh">Webhook URL</label>
<input type="url" id="sl-wh" placeholder="https://hooks.slack.com/services/...">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="sl-ch">Standardkanal</label>
<input type="text" id="sl-ch" placeholder="#alerts">
</div>
</div>
<div class="int-form card" id="if-github">
<div class="card-label">GitHub-konfiguration</div>
<div class="form-group">
<label for="gh-tok">Personal Access Token</label>
<input type="password" id="gh-tok" placeholder="ghp_...">
<div class="hint">Kräver repo och read:org behörigheter.</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label for="gh-org">Organisation (valfritt)</label>
<input type="text" id="gh-org" placeholder="my-organization">
</div>
</div>
<div class="int-form card" id="if-pd">
<div class="card-label">PagerDuty-konfiguration</div>
<div class="form-group" style="margin-bottom:0">
<label for="pd-key">Integration Key</label>
<input type="password" id="pd-key" placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
</div>
</div>
<div class="int-form card" id="if-smtp">
<div class="card-label">SMTP-konfiguration</div>
<div class="form-row">
<div class="form-group">
<label for="sm-host">SMTP-server</label>
<input type="text" id="sm-host" placeholder="smtp.example.com">
</div>
<div class="form-group">
<label for="sm-port">Port</label>
<input type="text" id="sm-port" placeholder="587">
</div>
</div>
<div class="form-row" style="margin-bottom:0">
<div class="form-group" style="margin-bottom:0">
<label for="sm-user">Användarnamn</label>
<input type="text" id="sm-user" placeholder="noreply@example.com">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="sm-pass">Lösenord</label>
<input type="password" id="sm-pass" placeholder="••••••••">
</div>
</div>
</div>
</div>
<!-- ── Step 5: Done ── -->
<div class="step" id="step-5">
<div class="done-wrap">
<div class="done-circle" id="done-circle"></div>
<h1 class="done-title">AAMOS är redo!</h1>
<p class="done-subtitle">Systemet har konfigurerats framgångsrikt. Alla valda moduler och integrationer är aktiverade och redo att användas.</p>
<div class="summary-pills" id="pills"></div>
<button class="btn btn-success" id="finish-btn" onclick="finish()">
<span class="btn-text">Starta AAMOS</span>
<div class="spinner"></div>
</button>
</div>
</div>
</div><!-- /steps-wrap -->
<!-- ── Footer ── -->
<div class="footer">
<span class="footer-info" id="step-ind">Steg 1 av 5</span>
<div class="footer-btns">
<button class="btn btn-ghost" id="back-btn" onclick="goBack()" style="display:none">← Tillbaka</button>
<button class="btn btn-secondary" id="skip-btn" onclick="goSkip()">Hoppa över</button>
<button class="btn btn-primary" id="next-btn" onclick="goNext()">
<span class="btn-text">Nästa</span>
<span></span>
</button>
</div>
</div>
</main>
</div>
<!-- Toast -->
<div id="toast">
<span id="toast-ico"></span>
<span id="toast-msg">Sparat!</span>
</div>
<script>
'use strict';
/* ── State ─────────────────────────────────────────────── */
const S = {
cur: 1,
total: 5,
done: new Set(),
activeInts: new Set()
};
const META = [
{ ey: 'Steg 1 av 5', t: 'Systeminformation', s: 'Bekräfta och konfigurera grundläggande miljöinställningar för AAMOS.' },
{ ey: 'Steg 2 av 5', t: 'Admin-användare', s: 'Skapa det primära administratörskontot med säkra inloggningsuppgifter.' },
{ ey: 'Steg 3 av 5', t: 'Aktivera moduler', s: 'Välj vilka tjänster och funktioner som ska vara aktiva i din installation.' },
{ ey: 'Steg 4 av 5', t: 'Integrationer', s: 'Anslut AAMOS till externa tjänster för notifikationer och CI/CD.' },
{ ey: 'Steg 5 av 5', t: 'Klar!', s: 'Din AAMOS-installation är konfigurerad och redo att startas.' }
];
/* ── Navigation ────────────────────────────────────────── */
function goNext() {
if (!validate(S.cur)) { toast('⚠️', 'Fyll i alla obligatoriska fält', 'warn'); return; }
S.done.add(S.cur);
if (S.cur < S.total) transition(S.cur + 1, 1);
}
function goBack() {
if (S.cur > 1) transition(S.cur - 1, -1);
}
function goSkip() {
if (S.cur < S.total) transition(S.cur + 1, 1);
}
function transition(target, dir) {
const from = document.getElementById('step-' + S.cur);
const to = document.getElementById('step-' + target);
from.style.transition = 'opacity .22s ease, transform .22s ease';
from.style.opacity = '0';
from.style.transform = dir > 0 ? 'translateX(-48px)' : 'translateX(48px)';
setTimeout(() => {
from.classList.remove('active');
from.style.cssText = '';
to.style.transition = 'none';
to.style.opacity = '0';
to.style.transform = dir > 0 ? 'translateX(48px)' : 'translateX(-48px)';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
to.style.transition = 'opacity .3s ease, transform .3s ease';
to.style.opacity = '1';
to.style.transform = 'translateX(0)';
to.classList.add('active');
});
});
S.cur = target;
refresh();
if (target === S.total) {
populatePills();
setTimeout(confetti, 420);
}
}, 200);
}
function refresh() {
/* progress */
const pct = Math.round((S.done.size / S.total) * 100);
document.getElementById('prog').style.width = pct + '%';
document.getElementById('pct').textContent = pct + '%';
/* nav items */
for (let i = 1; i <= S.total; i++) {
const el = document.getElementById('nav-' + i);
const b = el.querySelector('.nav-badge');
el.classList.remove('active', 'completed', 'disabled');
if (i === S.cur) {
el.classList.add('active');
b.textContent = i;
} else if (S.done.has(i)) {
el.classList.add('completed');
b.textContent = '✓';
} else {
el.classList.add('disabled');
b.textContent = i;
}
}
/* header */
const m = META[S.cur - 1];
const eyEl = document.getElementById('eyebrow');
const tiEl = document.getElementById('title');
const suEl = document.getElementById('subtitle');
[eyEl, tiEl, suEl].forEach(el => { el.style.transition = 'none'; el.style.opacity = '0'; });
setTimeout(() => {
eyEl.textContent = m.ey;
tiEl.textContent = m.t;
suEl.textContent = m.s;
[eyEl, tiEl, suEl].forEach(el => {
el.style.transition = 'opacity .28s ease';
el.style.opacity = '1';
});
}, 160);
/* footer */
document.getElementById('step-ind').textContent = `Steg ${S.cur} av ${S.total}`;
document.getElementById('back-btn').style.display = S.cur > 1 ? 'inline-flex' : 'none';
const isLast = S.cur === S.total;
document.getElementById('next-btn').style.display = isLast ? 'none' : 'inline-flex';
document.getElementById('skip-btn').style.display = isLast ? 'none' : 'inline-flex';
if (!isLast) document.getElementById('next-btn').querySelector('.btn-text').textContent = S.cur === S.total - 1 ? 'Slutför' : 'Nästa';
}
/* ── Validation ────────────────────────────────────────── */
function validate(step) {
if (step !== 2) return true;
const fn = v('a-fn'), ln = v('a-ln');
const em = v('a-email'),us = v('a-user');
const pw = v('a-pw'), conf = v('a-conf');
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em);
const userOk = /^[a-zA-Z0-9_-]{2,}$/.test(us);
const pwOk = pw.length >= 12;
const confOk = pw === conf && conf.length > 0;
err('fg-fn', !fn);
err('fg-ln', !ln);
err('fg-email',!emailOk);
err('fg-user', !userOk);
err('fg-pw', !pwOk);
err('fg-conf', !confOk);
return fn && ln && emailOk && userOk && pwOk && confOk;
}
function v(id) { return document.getElementById(id).value.trim(); }
function err(id, hasErr) {
document.getElementById(id).classList.toggle('has-error', hasErr);
}
/* ── Password strength ─────────────────────────────────── */
document.getElementById('a-pw').addEventListener('input', function () {
const pw = this.value;
const str = strength(pw);
const c = ['', '#FF3B30', '#FF9500', '#FFCC00', '#34C759'];
const l = ['', 'Mycket svagt', 'Svagt', 'Bra', 'Starkt'];
for (let i = 1; i <= 4; i++) {
document.getElementById('ps' + i).style.background = i <= str ? c[str] : 'var(--separator)';
}
const sw = document.getElementById('sw');
sw.textContent = pw ? l[str] : 'Ange ett lösenord';
sw.style.color = pw ? c[str] : 'var(--text-tertiary)';
});
function strength(pw) {
if (!pw || pw.length < 6) return 1;
let s = 0;
if (pw.length >= 12) s++;
if (/[A-Z]/.test(pw)) s++;
if (/[0-9]/.test(pw)) s++;
if (/[^A-Za-z0-9]/.test(pw))s++;
return Math.max(1, s);
}
/* ── Integration cards ─────────────────────────────────── */
const INT_MAP = {
slack: { card: 'ic-slack', status: 'is-slack', form: 'if-slack' },
github: { card: 'ic-github', status: 'is-github', form: 'if-github' },
pd: { card: 'ic-pd', status: 'is-pd', form: 'if-pd' },
smtp: { card: 'ic-smtp', status: 'is-smtp', form: 'if-smtp' }
};
Object.entries(INT_MAP).forEach(([key, ids]) => {
document.getElementById(ids.card).addEventListener('click', () => {
const on = S.activeInts.has(key);
const card = document.getElementById(ids.card);
const stat = document.getElementById(ids.status);
const form = document.getElementById(ids.form);
if (on) {
S.activeInts.delete(key);
card.classList.remove('connected');
stat.textContent = 'Tillval';
stat.className = 'int-status off';
if (form) { form.style.opacity = '0'; setTimeout(() => { form.style.display = 'none'; }, 280); }
} else {
S.activeInts.add(key);
card.classList.add('connected');
stat.textContent = 'Aktiverad';
stat.className = 'int-status on';
if (form) {
form.style.display = 'block';
form.style.opacity = '0';
requestAnimationFrame(() => {
form.style.transition = 'opacity .3s ease';
form.style.opacity = '1';
});
}
}
});
});
/* ── Summary pills ─────────────────────────────────────── */
function populatePills() {
const wrap = document.getElementById('pills');
wrap.innerHTML = '';
const items = [];
const n = document.getElementById('s-name').value || 'AAMOS';
const e = document.getElementById('s-env').value;
items.push(n, e.charAt(0).toUpperCase() + e.slice(1));
const fn = document.getElementById('a-fn').value;
const ln = document.getElementById('a-ln').value;
if (fn) items.push(`Admin: ${fn} ${ln}`.trim());
const mods = { 'm-log': 'Logging', 'm-ai': 'AI/ML', 'm-bak': 'Backup', 'm-apigw': 'API Gateway', 'm-mq': 'Message Queue', 'm-bi': 'Analytics' };
Object.entries(mods).forEach(([id, lbl]) => {
if (document.getElementById(id).checked) items.push(lbl);
});
const intNames = { slack: 'Slack', github: 'GitHub', pd: 'PagerDuty', smtp: 'SMTP' };
S.activeInts.forEach(k => items.push(intNames[k] || k));
items.forEach(txt => {
wrap.insertAdjacentHTML('beforeend',
`<div class="pill"><span class="pill-dot"></span>${txt}</div>`);
});
}
/* ── Finish ────────────────────────────────────────────── */
async function finish() {
const btn = document.getElementById('finish-btn');
btn.classList.add('loading');
btn.disabled = true;
const payload = {
system: {
name: document.getElementById('s-name').value,
environment: document.getElementById('s-env').value,
timezone: document.getElementById('s-tz').value,
language: document.getElementById('s-lang').value,
domain: document.getElementById('s-domain').value
},
admin: {
firstname: document.getElementById('a-fn').value,
lastname: document.getElementById('a-ln').value,
email: document.getElementById('a-email').value,
username: document.getElementById('a-user').value,
password: document.getElementById('a-pw').value,
role: document.getElementById('a-role').value
},
modules: {
monitoring: true,
security: true,
logging: document.getElementById('m-log').checked,
ai_ml: document.getElementById('m-ai').checked,
backup: document.getElementById('m-bak').checked,
api_gateway: document.getElementById('m-apigw').checked,
message_queue: document.getElementById('m-mq').checked,
analytics: document.getElementById('m-bi').checked
},
integrations: {
slack: S.activeInts.has('slack') ? { webhook: document.getElementById('sl-wh').value, channel: document.getElementById('sl-ch').value } : null,
github: S.activeInts.has('github') ? { token: document.getElementById('gh-tok').value, org: document.getElementById('gh-org').value } : null,
pagerduty: S.activeInts.has('pd') ? { key: document.getElementById('pd-key').value } : null,
smtp: S.activeInts.has('smtp') ? { host: document.getElementById('sm-host').value, port: document.getElementById('sm-port').value, user: document.getElementById('sm-user').value } : null
}
};
try {
const res = await fetch('/api/v1/onboarding/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('HTTP ' + res.status);
toast('🚀', 'AAMOS startar — omdirigerar...');
confetti();
setTimeout(() => { window.location.href = '/'; }, 2400);
} catch {
/* dev fallback — log payload and restore button */
console.info('[onboarding] payload:', JSON.stringify(payload, null, 2));
toast('🚀', 'AAMOS startar — omdirigerar...');
confetti();
setTimeout(() => { btn.classList.remove('loading'); btn.disabled = false; }, 2200);
}
}
/* ── Toast ─────────────────────────────────────────────── */
let toastTimer;
function toast(ico, msg, type) {
const el = document.getElementById('toast');
document.getElementById('toast-ico').textContent = ico;
document.getElementById('toast-msg').textContent = msg;
el.style.borderColor = type === 'warn' ? 'var(--warning)' : type === 'err' ? 'var(--danger)' : 'var(--success)';
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3600);
}
/* ── Confetti ──────────────────────────────────────────── */
function confetti() {
const cv = document.getElementById('confetti');
const ctx = cv.getContext('2d');
cv.width = window.innerWidth;
cv.height = window.innerHeight;
const COLORS = ['#007AFF','#34C759','#FF9500','#FF3B30','#AF52DE','#5AC8FA','#FFD60A','#FF375F','#30D158'];
const parts = Array.from({ length: 200 }, () => ({
x: Math.random() * cv.width,
y: -30 - Math.random() * 220,
w: 5 + Math.random() * 9,
h: 10 + Math.random() * 10,
vx: (Math.random() - .5) * 4.5,
vy: 2.2 + Math.random() * 4,
rot: Math.random() * 360,
rs: (Math.random() - .5) * 9,
col: COLORS[Math.floor(Math.random() * COLORS.length)],
op: 1,
circ: Math.random() > .5
}));
let frame = 0;
(function draw() {
ctx.clearRect(0, 0, cv.width, cv.height);
let alive = false;
parts.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.07;
p.rot += p.rs;
if (frame > 90) p.op = Math.max(0, p.op - .012);
if (p.y < cv.height + 50) alive = true;
ctx.save();
ctx.globalAlpha = p.op;
ctx.translate(p.x + p.w / 2, p.y + p.h / 2);
ctx.rotate(p.rot * Math.PI / 180);
ctx.fillStyle = p.col;
if (p.circ) {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
});
frame++;
if (alive && frame < 320) requestAnimationFrame(draw);
else ctx.clearRect(0, 0, cv.width, cv.height);
})();
}
window.addEventListener('resize', () => {
const cv = document.getElementById('confetti');
cv.width = window.innerWidth;
cv.height = window.innerHeight;
});
/* ── Boot ──────────────────────────────────────────────── */
refresh();
</script>
</body>
</html>