Files
boc/backups/2026-07-12-1023/quixzoom-landing-prod/launch-status.html
T
Bernt 58ca4e68db feat(boc): Complete Business Operations Center v1.0
- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics)
- Rust analytics service with parallel report generation
- C runtime with POSIX shared memory IPC
- PostgreSQL schema with 30+ tables, full migrations
- Redis cache, sessions, pub/sub
- Kafka event streaming with Zookeeper
- WebSocket hub for real-time updates
- Automation engine with cron jobs, workflows, event triggers
- JWT authentication, multi-tenant from start
- Docker Compose with all services
- Nginx reverse proxy with rate limiting
- Integration tests passing
- Feature gap analysis against Fortnox/Odoo/Visma

Refs: BOC-001
2026-07-12 12:41:35 +00:00

662 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>quiXzoom — Launch Status</title>
<style>
/* iOS System Font Stack */
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--ios-blue: #007AFF;
--ios-green: #34C759;
--ios-orange: #FF9500;
--ios-red: #FF3B30;
--ios-purple: #AF52DE;
--ios-gray: #8E8E93;
--ios-gray2: #AEAEB2;
--ios-gray6: #F2F2F7;
--ios-label: #000000;
--ios-label2: #3C3C43;
--ios-bg: #F2F2F7;
--ios-card: #FFFFFF;
--ios-sep: rgba(60,60,67,0.12);
--qx-accent: #FF6D00;
}
body {
font-family: -apple-system, 'SF Pro Display', 'Helvetica Neue', sans-serif;
background: var(--ios-bg);
color: var(--ios-label);
min-height: 100dvh;
padding-bottom: env(safe-area-inset-bottom, 20px);
}
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px 8px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.3px;
}
.status-bar .time { font-variant-numeric: tabular-nums; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* Navigation Bar */
.nav-bar {
padding: 6px 16px 12px;
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.nav-bar h1 {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.5px;
line-height: 1;
}
.nav-bar h1 span { color: var(--qx-accent); }
.launch-badge {
background: var(--ios-green);
color: white;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border-radius: var(--radius-md);
letter-spacing: 0.3px;
}
.launch-badge.warning { background: var(--ios-orange); }
/* Scroll Container */
.scroll { overflow-y: auto; padding: 0 16px 32px; }
/* Hero Card */
.hero-card {
background: linear-gradient(135deg, #FF6D00 0%, #FF3D00 100%);
border-radius: var(--radius-xl);
padding: 24px;
color: white;
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.hero-card::before {
content: '';
position: absolute;
right: -30px; top: -30px;
width: 140px; height: 140px;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.12);
}
.hero-card::after {
content: '';
position: absolute;
right: 30px; top: 40px;
width: 80px; height: 80px;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.08);
}
.hero-label {
font-size: 13px;
font-weight: 500;
opacity: 0.85;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
}
.hero-date {
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 4px;
}
.hero-sub {
font-size: 15px;
opacity: 0.9;
margin-bottom: 20px;
}
.hero-countdown {
display: flex;
gap: 12px;
}
.countdown-unit {
background: rgba(255,255,255,0.22);
border-radius: var(--radius-lg);
padding: 12px 16px;
text-align: center;
backdrop-filter: blur(4px);
}
.countdown-unit .val {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
display: block;
}
.countdown-unit .lbl {
font-size: 10px;
opacity: 0.75;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Section Header */
.section-header {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
margin: 24px 0 10px;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--ios-card);
border-radius: var(--radius-lg);
padding: 18px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.stat-card .icon {
font-size: 28px;
margin-bottom: 8px;
display: block;
}
.stat-card .val {
font-size: 30px;
font-weight: 700;
letter-spacing: -1px;
display: block;
line-height: 1;
margin-bottom: 4px;
}
.stat-card .lbl {
font-size: 13px;
color: var(--ios-gray);
font-weight: 500;
}
/* Progress Bar */
.readiness-card {
background: var(--ios-card);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.readiness-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.readiness-pct {
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
}
.readiness-label { font-size: 15px; font-weight: 500; color: var(--ios-gray); }
.progress-track {
background: var(--ios-gray6);
border-radius: var(--radius-md);
height: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--ios-green) 0%, #30D158 100%);
transition: width 1.2s cubic-bezier(0.16,1,0.3,1);
}
.readiness-sub {
font-size: 13px;
color: var(--ios-gray);
}
/* Cities */
.cities-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.city-chip {
background: var(--ios-card);
border-radius: var(--radius-xl);
padding: 8px 14px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.city-chip .dot {
width: 8px; height: 8px;
border-radius: var(--radius-full);
background: var(--ios-green);
}
/* Checklist */
.checklist-group {
background: var(--ios-card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
margin-bottom: 20px;
}
.check-item {
display: flex;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--ios-sep);
gap: 12px;
}
.check-item:last-child { border-bottom: none; }
.check-icon {
width: 32px; height: 32px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.check-icon.done { background: rgba(52,199,89,0.15); }
.check-icon.pending { background: rgba(255,149,0,0.15); }
.check-body { flex: 1; min-width: 0; }
.check-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 2px;
}
.check-detail {
font-size: 12px;
color: var(--ios-gray);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check-badge {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-md);
letter-spacing: 0.2px;
flex-shrink: 0;
}
.check-badge.done { background: rgba(52,199,89,0.15); color: #27a745; }
.check-badge.pending { background: rgba(255,149,0,0.15); color: #c87000; }
/* Footer */
.footer {
text-align: center;
font-size: 13px;
color: var(--ios-gray);
padding: 16px 0 8px;
}
.footer strong { color: var(--ios-label); }
/* Skeleton / loading */
.skeleton {
background: linear-gradient(90deg, #e8e8e8 25%, #f5f5f5 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes shimmer { to { background-position: -200% 0; } }
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--ios-bg: #000000;
--ios-card: #1C1C1E;
--ios-label: #FFFFFF;
--ios-gray6: #2C2C2E;
--ios-sep: rgba(255,255,255,0.1);
}
.skeleton { background: linear-gradient(90deg, #2a2a2a 25%, #383838 50%, #2a2a2a 75%); background-size: 200% 100%; }
}
/* Pulse dot */
.live-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: var(--radius-full);
background: var(--ios-green);
box-shadow: 0 0 0 0 rgba(52,199,89,0.5);
animation: pulse 2s infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(52,199,89,0.5); }
70% { box-shadow: 0 0 0 8px rgba(52,199,89,0); }
100%{ box-shadow: 0 0 0 0 rgba(52,199,89,0); }
}
/* Refresh button */
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
background: var(--ios-blue);
color: white;
border: none;
border-radius: var(--radius-lg);
padding: 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
font-family: inherit;
letter-spacing: -0.3px;
}
.refresh-btn:active { opacity: 0.8; transform: scale(0.98); }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
<style id="wcag-contrast-enforcement">
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
/* Regel: Ingen text under 70% opacity. Aldrig. */
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* Fix common low-contrast patterns */
[style*="rgba(60,60,67,0.4)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(60,60,67,0.3)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(255,255,255,0.4)"] { color: rgba(255,255,255,0.72) !important; }
[style*="rgba(255,255,255,0.3)"] { color: rgba(255,255,255,0.72) !important; }
[style*="opacity: 0.4"] { opacity: 0.72 !important; }
[style*="opacity: 0.3"] { opacity: 0.72 !important; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
</head>
<body>
<!-- Status Bar -->
<div class="status-bar">
<span class="time" id="clock">9:41</span>
<span class="icons">
<svg width="17" height="12" viewBox="0 0 17 12" fill="currentColor"><rect x="0" y="3" width="3" height="9" rx="1" opacity="0.35"/><rect x="4" y="2" width="3" height="10" rx="1" opacity="0.55"/><rect x="8" y="1" width="3" height="11" rx="1" opacity="0.75"/><rect x="12" y="0" width="3" height="12" rx="1"/></svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="currentColor"><path d="M8 2.5C10.2 2.5 12.2 3.5 13.5 5.1L15 3.4C13.3 1.3 10.8 0 8 0S2.7 1.3 1 3.4L2.5 5.1C3.8 3.5 5.8 2.5 8 2.5zM8 5.5C9.5 5.5 10.8 6.1 11.7 7.1L13.2 5.4C11.9 4 10 3 8 3S4.1 4 2.8 5.4L4.3 7.1C5.2 6.1 6.5 5.5 8 5.5zM8 8.5c.9 0 1.7.4 2.3 1L8 12 5.7 9.5c.6-.6 1.4-1 2.3-1z"/></svg>
<svg width="25" height="12" viewBox="0 0 25 12" fill="currentColor"><rect x="0" y="1" width="21" height="10" rx="3.5" stroke="currentColor" stroke-width="1" fill="none" opacity="0.35"/><rect x="1.5" y="2.5" width="17" height="7" rx="2" fill="currentColor"/><rect x="22" y="4" width="3" height="4" rx="1.5" opacity="0.4"/></svg>
</span>
</div>
<!-- Nav Bar -->
<div class="nav-bar">
<h1>quiX<span>zoom</span></h1>
<div class="launch-badge" id="go-badge">Laddar...</div>
</div>
<!-- Scroll Area -->
<div class="scroll">
<!-- Hero Card -->
<div class="hero-card">
<div class="hero-label">Launch Sverige</div>
<div class="hero-date">1 augusti 2026</div>
<div class="hero-sub"><span class="live-dot"></span>Live tracking</div>
<div class="hero-countdown">
<div class="countdown-unit">
<span class="val" id="cd-days"></span>
<span class="lbl">Dagar</span>
</div>
<div class="countdown-unit">
<span class="val" id="cd-hours"></span>
<span class="lbl">Timmar</span>
</div>
<div class="countdown-unit">
<span class="val" id="cd-mins"></span>
<span class="lbl">Minuter</span>
</div>
</div>
</div>
<!-- Stats -->
<div class="section-header">Live-statistik</div>
<div class="stats-row">
<div class="stat-card">
<span class="icon"></span>
<span class="val" id="stat-missions"></span>
<span class="lbl">Uppdrag aktiva</span>
</div>
<div class="stat-card">
<span class="icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg></span>
<span class="val" id="stat-zoomers"></span>
<span class="lbl">Zoomers aktiva</span>
</div>
<div class="stat-card">
<span class="icon"></span>
<span class="val" id="stat-cities"></span>
<span class="lbl">Städer täckta</span>
</div>
<div class="stat-card">
<span class="icon"></span>
<span class="val" id="stat-days"></span>
<span class="lbl">Dagar kvar</span>
</div>
</div>
<!-- Readiness -->
<div class="readiness-card">
<div class="readiness-top">
<div>
<div class="readiness-pct" id="readiness-pct">—%</div>
<div class="readiness-label">Launch Readiness</div>
</div>
<div style="font-size:13px; color:var(--ios-gray); text-align:right;">
<span id="done-count"></span> / <span id="total-count"></span><br>klara
</div>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
<div class="readiness-sub" id="readiness-sub">Hämtar status...</div>
</div>
<!-- Cities -->
<div class="section-header">Täckningsområde</div>
<div class="cities-row" id="cities-row">
<div class="city-chip skeleton" style="width:100px; height:36px;"></div>
<div class="city-chip skeleton" style="width:90px; height:36px;"></div>
<div class="city-chip skeleton" style="width:80px; height:36px;"></div>
</div>
<!-- Checklist: Done -->
<div class="section-header">Klart <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="checklist-group" id="done-list">
<div class="check-item">
<div class="check-icon done skeleton" style="background:transparent"></div>
<div class="check-body">
<div class="skeleton" style="height:14px; width:70%; margin-bottom:6px;"></div>
<div class="skeleton" style="height:11px; width:50%;"></div>
</div>
</div>
</div>
<!-- Checklist: Pending -->
<div class="section-header">Återstår ⏳</div>
<div class="checklist-group" id="pending-list">
<div class="check-item">
<div class="check-icon pending skeleton" style="background:transparent"></div>
<div class="check-body">
<div class="skeleton" style="height:14px; width:60%; margin-bottom:6px;"></div>
<div class="skeleton" style="height:11px; width:45%;"></div>
</div>
</div>
</div>
<button class="refresh-btn" onclick="loadData()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Uppdatera
</button>
<div class="footer">
<strong>quiXzoom</strong> — Wavult Group &nbsp;·&nbsp; <span id="last-updated"></span>
</div>
</div><!-- /scroll -->
<script>
// Clock
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
now.getHours().toString().padStart(2,'0') + ':' +
now.getMinutes().toString().padStart(2,'0');
}
updateClock();
setInterval(updateClock, 30000);
// Countdown
function updateCountdown() {
const launch = new Date('2026-06-15T07:00:00+02:00');
const now = new Date();
const diff = Math.max(0, launch - now);
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
document.getElementById('cd-days').textContent = d;
document.getElementById('cd-hours').textContent = h.toString().padStart(2,'0');
document.getElementById('cd-mins').textContent = m.toString().padStart(2,'0');
}
updateCountdown();
setInterval(updateCountdown, 60000);
// Build checklist item
const ICONS = { done: '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', pending: '⏳' };
function buildItem(c) {
const div = document.createElement('div');
div.className = 'check-item';
div.innerHTML = `
<div class="check-icon ${c.status}">${ICONS[c.status] || '·'}</div>
<div class="check-body">
<div class="check-title">${c.label}</div>
<div class="check-detail">${c.detail || ''}</div>
</div>
<span class="check-badge ${c.status}">${c.status === 'done' ? 'Klar' : 'Väntar'}</span>
`;
return div;
}
// Load data
async function loadData() {
try {
// Use a dummy JWT for auth (dev mode)
const demoToken = 'eyJhbGciOiJub25lIn0.' +
btoa(JSON.stringify({ sub: 'launch-status-ui', email: 'bernt@wavult.com' })) + '.';
const [readRes, statsRes] = await Promise.all([
fetch('https://api.quixzoom.com/api/qz/launch-readiness', {
headers: { Authorization: 'Bearer ' + demoToken }
}),
fetch('https://api.quixzoom.com/api/qz/stats', {
headers: { Authorization: 'Bearer ' + demoToken }
})
]);
const data = await readRes.json();
const stats = statsRes.ok ? await statsRes.json() : null;
// Stats cards
const activeMissions = stats ? (stats.missions.open + stats.missions.active) : data.summary.done;
const activeZoomers = stats ? (stats.zoomers.available + stats.zoomers.busy) : 3;
document.getElementById('stat-missions').textContent = activeMissions;
document.getElementById('stat-zoomers').textContent = activeZoomers;
document.getElementById('stat-cities').textContent = data.summary.cities;
document.getElementById('stat-days').textContent = data.days_to_launch;
// Readiness
const pct = data.readiness_pct;
document.getElementById('readiness-pct').textContent = pct + '%';
document.getElementById('done-count').textContent = data.summary.done;
document.getElementById('total-count').textContent = data.summary.total;
document.getElementById('readiness-sub').textContent =
data.launch_go
? '🟢 Launch Go — klar för 1 augusti!'
: `${data.summary.pending} saker återstår innan launch`;
setTimeout(() => {
document.getElementById('progress-fill').style.width = pct + '%';
}, 100);
// Go badge
const badge = document.getElementById('go-badge');
if (data.launch_go) {
badge.textContent = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2L10 8L14 9L8 14L6 8L2 7L8 2Z" fill="#666"/></svg> GO';
badge.className = 'launch-badge';
} else {
badge.textContent = '⏳ ' + data.days_to_launch + 'd kvar';
badge.className = 'launch-badge warning';
}
// Cities
const citiesRow = document.getElementById('cities-row');
citiesRow.innerHTML = '';
(data.summary.cities_list || []).forEach(city => {
const chip = document.createElement('div');
chip.className = 'city-chip';
chip.innerHTML = `<span class="dot"></span>${city}`;
citiesRow.appendChild(chip);
});
// Checklist
const doneList = document.getElementById('done-list');
const pendingList = document.getElementById('pending-list');
doneList.innerHTML = '';
pendingList.innerHTML = '';
data.checklist
.filter(c => c.status === 'done')
.forEach(c => doneList.appendChild(buildItem(c)));
data.checklist
.filter(c => c.status !== 'done')
.forEach(c => pendingList.appendChild(buildItem(c)));
if (doneList.children.length === 0)
doneList.innerHTML = '<div class="check-item" style="justify-content:center;color:var(--ios-gray);font-size:14px;padding:20px;">Inga klara ännu</div>';
if (pendingList.children.length === 0)
pendingList.innerHTML = '<div class="check-item" style="justify-content:center;color:var(--ios-green);font-size:14px;padding:20px;">Allt klart! </div>';
// Timestamp
document.getElementById('last-updated').textContent =
'Uppdaterat ' + new Date().toLocaleTimeString('sv-SE', { hour:'2-digit', minute:'2-digit' });
} catch (err) {
console.error('Launch readiness load error:', err);
document.getElementById('readiness-sub').textContent = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg> Kunde inte ladda data — kontrollera API';
document.getElementById('go-badge').textContent = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg> Fel';
document.getElementById('go-badge').className = 'launch-badge warning';
}
}
// Load on start and auto-refresh every 60s
loadData();
setInterval(loadData, 60000);
</script>
</body>
</html>