1a12fb870b
- New /developers/ page with API docs, SDKs, pricing, use cases - OpenAPI 3.0 spec for Orders, Missions, Photos, Analytics - Case study: Glasskiosken i Flatenbadet — complete ROI analysis - Updated /order/ with recurring missions and frequency dropdown
1016 lines
33 KiB
HTML
1016 lines
33 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<title>quiXzoom — Mission Map</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<style>
|
||
* { 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-gray: #8E8E93;
|
||
--ios-gray2: #AEAEB2;
|
||
--ios-gray5: #E5E5EA;
|
||
--ios-gray6: #F2F2F7;
|
||
--ios-white: #FFFFFF;
|
||
--ios-label: #000000;
|
||
--ios-label2: rgba(60,60,67,0.6);
|
||
--ios-label3: rgba(60,60,67,0.3);
|
||
--ios-separator: rgba(60,60,67,0.29);
|
||
--safe-top: env(safe-area-inset-top, 0px);
|
||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||
}
|
||
|
||
html, body {
|
||
height: 100%;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif;
|
||
background: var(--ios-gray6);
|
||
color: var(--ios-label);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Header */
|
||
#header {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0;
|
||
z-index: 1000;
|
||
background: rgba(242,242,247,0.85);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
border-bottom: 0.5px solid var(--ios-separator);
|
||
padding: calc(var(--safe-top) + 12px) 16px 12px;
|
||
}
|
||
|
||
#header-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
#header-title {
|
||
flex: 1;
|
||
}
|
||
|
||
#header-title h1 {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.4px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
#header-title p {
|
||
font-size: 12px;
|
||
color: var(--ios-label2);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
#refresh-btn {
|
||
width: 32px; height: 32px;
|
||
border-radius: var(--radius-full);
|
||
background: var(--ios-blue);
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
#refresh-btn:active { opacity: 0.7; }
|
||
|
||
#refresh-btn svg { width: 16px; height: 16px; fill: white; }
|
||
|
||
/* Search/Filter Bar */
|
||
#search-bar {
|
||
position: fixed;
|
||
top: calc(var(--safe-top) + 60px);
|
||
left: 0; right: 0;
|
||
z-index: 1000;
|
||
padding: 8px 16px;
|
||
background: rgba(242,242,247,0.85);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
#search-input-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
#search-input-wrap svg {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 14px; height: 14px;
|
||
fill: var(--ios-gray2);
|
||
pointer-events: none;
|
||
}
|
||
|
||
#search-input {
|
||
width: 100%;
|
||
height: 36px;
|
||
background: var(--ios-white);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
padding: 0 10px 0 32px;
|
||
font-size: 15px;
|
||
font-family: inherit;
|
||
color: var(--ios-label);
|
||
outline: none;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
#search-input::placeholder { color: var(--ios-gray2); }
|
||
|
||
#cat-filter {
|
||
height: 36px;
|
||
background: var(--ios-white);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
padding: 0 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
color: var(--ios-label);
|
||
outline: none;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||
min-width: 100px;
|
||
}
|
||
|
||
/* Map */
|
||
#map {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
.leaflet-control-zoom {
|
||
margin-top: calc(var(--safe-top) + 128px) !important;
|
||
margin-left: 16px !important;
|
||
}
|
||
|
||
.leaflet-control-zoom a {
|
||
font-size: 18px !important;
|
||
width: 36px !important;
|
||
height: 36px !important;
|
||
line-height: 36px !important;
|
||
border-radius: var(--radius-md) !important;
|
||
}
|
||
|
||
/* Custom Pins */
|
||
.mission-pin {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: var(--radius-full);
|
||
border: 2.5px solid white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: transform 0.15s;
|
||
}
|
||
|
||
.mission-pin:hover { transform: scale(1.15); }
|
||
|
||
.pin-open { background: var(--ios-blue); }
|
||
.pin-assigned { background: var(--ios-orange); }
|
||
.pin-completed { background: var(--ios-green); }
|
||
|
||
/* Popup */
|
||
.leaflet-popup-content-wrapper {
|
||
border-radius: var(--radius-lg) !important;
|
||
box-shadow: 0 8px 30px rgba(0,0,0,0.18) !important;
|
||
padding: 0 !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.leaflet-popup-content {
|
||
margin: 0 !important;
|
||
width: 260px !important;
|
||
}
|
||
|
||
.leaflet-popup-tip-container { display: none; }
|
||
|
||
.popup-card {
|
||
padding: 16px;
|
||
background: var(--ios-white);
|
||
}
|
||
|
||
.popup-category {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--ios-blue);
|
||
background: rgba(0,122,255,0.1);
|
||
border-radius: var(--radius-sm);
|
||
padding: 3px 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.popup-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.3px;
|
||
line-height: 1.3;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.popup-meta {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
.popup-meta-item {
|
||
background: var(--ios-gray6);
|
||
border-radius: var(--radius-md);
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
.popup-meta-label {
|
||
font-size: 11px;
|
||
color: var(--ios-label2);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.popup-meta-value {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.popup-reward { color: var(--ios-green); }
|
||
|
||
.popup-status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
border-radius: var(--radius-xl);
|
||
padding: 4px 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.status-open { background: rgba(0,122,255,0.1); color: var(--ios-blue); }
|
||
.status-assigned { background: rgba(255,149,0,0.1); color: var(--ios-orange); }
|
||
.status-completed { background: rgba(52,199,89,0.1); color: var(--ios-green); }
|
||
|
||
.popup-risk {
|
||
margin-top: 10px;
|
||
border-top: 0.5px solid var(--ios-gray5);
|
||
padding-top: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
.risk-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
border-radius: var(--radius-md);
|
||
padding: 4px 9px;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
.risk-L1 { background: rgba(52,199,89,0.12); color: #1c7a3a; }
|
||
.risk-L2 { background: rgba(255,204,0,0.15); color: #7a5a00; }
|
||
.risk-L3 { background: rgba(255,149,0,0.15); color: #a05a00; }
|
||
.risk-L4 { background: rgba(255,59,48,0.13); color: #a01a10; }
|
||
.risk-hint { font-size: 11px; color: var(--ios-label2); line-height: 1.3; flex: 1; text-align: right; }
|
||
|
||
/* Bottom Sheet */
|
||
#bottom-sheet {
|
||
position: fixed;
|
||
bottom: 0; left: 0; right: 0;
|
||
z-index: 900;
|
||
background: rgba(242,242,247,0.92);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
border-top: 0.5px solid var(--ios-separator);
|
||
border-radius: var(--radius-xl) 20px 0 0;
|
||
padding: 12px 16px calc(var(--safe-bottom) + 12px);
|
||
}
|
||
|
||
#sheet-handle {
|
||
width: 36px; height: 4px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--ios-label3);
|
||
margin: 0 auto 14px;
|
||
}
|
||
|
||
#stats-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.stat-card {
|
||
flex: 1;
|
||
background: var(--ios-white);
|
||
border-radius: var(--radius-lg);
|
||
padding: 12px 14px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 11px;
|
||
color: var(--ios-label2);
|
||
font-weight: 500;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.5px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.stat-value.blue { color: var(--ios-blue); }
|
||
.stat-value.green { color: var(--ios-green); }
|
||
.stat-value.orange { color: var(--ios-orange); }
|
||
|
||
#legend-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--ios-label2);
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 10px; height: 10px;
|
||
border-radius: var(--radius-full);
|
||
}
|
||
|
||
/* Loading Indicator */
|
||
#loading-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
z-index: 2000;
|
||
background: var(--ios-gray6);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
#loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
||
|
||
.spinner {
|
||
width: 40px; height: 40px;
|
||
border: 3px solid var(--ios-gray5);
|
||
border-top-color: var(--ios-blue);
|
||
border-radius: var(--radius-full);
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
#loading-text {
|
||
font-size: 14px;
|
||
color: var(--ios-label2);
|
||
}
|
||
|
||
/* Urban Intelligence Layer */
|
||
#ui-toggle {
|
||
position: fixed;
|
||
top: calc(var(--safe-top) + 110px);
|
||
right: 16px;
|
||
z-index: 1000;
|
||
background: rgba(242,242,247,0.92);
|
||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||
backdrop-filter: blur(20px) saturate(180%);
|
||
border: 0.5px solid var(--ios-separator);
|
||
border-radius: var(--radius-md);
|
||
padding: 8px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--ios-label);
|
||
user-select: none;
|
||
transition: opacity 0.2s;
|
||
}
|
||
#ui-toggle:active { opacity: 0.7; }
|
||
#ui-dot { width:10px;height:10px;border-radius: var(--radius-full);background:var(--ios-blue);flex-shrink:0; }
|
||
#ui-toggle.off #ui-dot { background: var(--ios-gray2); }
|
||
|
||
.ui-popup { font-family: -apple-system,BlinkMacSystemFont,'SF Pro Text',sans-serif; min-width:220px; }
|
||
.ui-popup-header { display:flex;align-items:center;justify-content:space-between;margin-bottom:8px; }
|
||
.ui-popup-title { font-size:13px;font-weight:700;letter-spacing:-0.2px;color:#1c1c1e; }
|
||
.ui-confidence { font-size:11px;color:#8e8e93;background:#f2f2f7;border-radius: var(--radius-sm);padding:2px 7px;font-weight:500; }
|
||
.ui-scores { display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px; }
|
||
.ui-score-item { background:#f2f2f7;border-radius: var(--radius-md);padding:6px 8px; }
|
||
.ui-score-label { font-size:10px;color:#8e8e93;font-weight:500;margin-bottom:2px; }
|
||
.ui-score-bar { height:4px;border-radius: var(--radius-sm);background:#e5e5ea;margin-bottom:2px; }
|
||
.ui-score-fill { height:100%;border-radius: var(--radius-sm); }
|
||
.ui-score-val { font-size:12px;font-weight:700;color:#1c1c1e; }
|
||
.ui-contradiction { border-radius: var(--radius-md);padding:7px 10px;font-size:12px;line-height:1.5;margin-bottom:8px; }
|
||
.ui-contradiction.low { background:rgba(52,199,89,0.12);color:#1c7a3a; }
|
||
.ui-contradiction.medium { background:rgba(255,149,0,0.12);color:#a05a00; }
|
||
.ui-contradiction.high { background:rgba(255,59,48,0.12);color:#a01a10; }
|
||
.ui-contradiction-label { font-weight:700;display:block;margin-bottom:2px; }
|
||
.ui-assessment { font-size:11px;color:#8e8e93;border-top:0.5px solid #e5e5ea;padding-top:6px;line-height:1.4; }
|
||
|
||
/* Refresh pulse */
|
||
@keyframes pulse-ring {
|
||
0% { box-shadow: 0 0 0 0 rgba(0,122,255,0.4); }
|
||
70% { box-shadow: 0 0 0 8px rgba(0,122,255,0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(0,122,255,0); }
|
||
}
|
||
|
||
#refresh-btn.loading { animation: pulse-ring 1s ease-out infinite; }
|
||
@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; }
|
||
}
|
||
/* QUIXZOOM STANDARD — Dark Theme */
|
||
:root {
|
||
--qz-bg: #0a0a0a;
|
||
--qz-surface: #111111;
|
||
--qz-surface-2: #1a1a1a;
|
||
--qz-border: rgba(255,255,255,0.08);
|
||
--qz-text: #e8e8e8;
|
||
--qz-text-dim: #a0a0a0;
|
||
--qz-text-muted: #6b6b6b;
|
||
--qz-blue: #007AFF;
|
||
--qz-blue-dark: #0056CC;
|
||
--qz-radius: 10px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
||
background: var(--qz-bg);
|
||
color: var(--qz-text);
|
||
line-height: 1.6;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* Navigation */
|
||
.qz-nav {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 24px; height: 56px;
|
||
background: rgba(255,255,255,0.92);
|
||
backdrop-filter: blur(16px);
|
||
border-bottom: 1px solid var(--qz-border);
|
||
}
|
||
|
||
.qz-nav-logo { font-size: 18px; font-weight: 700; color: #fff; text-decoration: none; }
|
||
.qz-nav-links { display: flex; gap: 24px; list-style: none; }
|
||
.qz-nav-links a { font-size: 14px; color: var(--qz-text-dim); text-decoration: none; transition: color 0.2s; }
|
||
.qz-nav-links a:hover { color: #fff; }
|
||
|
||
/* Buttons */
|
||
.qz-btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 12px 24px; border-radius: var(--qz-radius);
|
||
font-size: 15px; font-weight: 600; text-decoration: none;
|
||
transition: all 0.2s; cursor: pointer; border: none;
|
||
}
|
||
|
||
.qz-btn-primary {
|
||
background: var(--qz-blue); color: #fff;
|
||
}
|
||
.qz-btn-primary:hover {
|
||
background: var(--qz-blue-dark); transform: translateY(-1px);
|
||
}
|
||
|
||
.qz-btn-ghost {
|
||
background: transparent; color: var(--qz-text);
|
||
border: 1px solid var(--qz-border);
|
||
}
|
||
.qz-btn-ghost:hover {
|
||
border-color: var(--qz-blue); color: var(--qz-blue);
|
||
}
|
||
|
||
/* Cards */
|
||
.qz-card {
|
||
background: var(--qz-surface);
|
||
border: 1px solid var(--qz-border);
|
||
border-radius: var(--qz-radius);
|
||
padding: 24px;
|
||
transition: transform 0.2s, border-color 0.2s;
|
||
}
|
||
.qz-card:hover {
|
||
transform: translateY(-2px);
|
||
border-color: rgba(0,122,255,0.3);
|
||
}
|
||
|
||
/* Footer */
|
||
.qz-footer {
|
||
border-top: 1px solid var(--qz-border);
|
||
padding: 40px 24px;
|
||
text-align: center;
|
||
color: var(--qz-text-dim);
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Banner */
|
||
.qz-banner {
|
||
background: #001E50;
|
||
color: rgba(255,255,255,0.55);
|
||
text-align: center;
|
||
padding: 10px;
|
||
font-size: 12px;
|
||
}
|
||
.qz-banner a { color: rgba(255,255,255,0.8); text-decoration: none; }
|
||
|
||
@media (max-width: 768px) {
|
||
.qz-nav-links { display: none; }
|
||
}
|
||
</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; }
|
||
}
|
||
/* QUIXZOOM STANDARD — Dark Theme */
|
||
:root {
|
||
--qz-bg: #0a0a0a;
|
||
--qz-surface: #111111;
|
||
--qz-surface-2: #1a1a1a;
|
||
--qz-border: rgba(255,255,255,0.08);
|
||
--qz-text: #e8e8e8;
|
||
--qz-text-dim: #a0a0a0;
|
||
--qz-text-muted: #6b6b6b;
|
||
--qz-blue: #007AFF;
|
||
--qz-blue-dark: #0056CC;
|
||
--qz-radius: 10px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
||
background: var(--qz-bg);
|
||
color: var(--qz-text);
|
||
line-height: 1.6;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* Navigation */
|
||
.qz-nav {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 24px; height: 56px;
|
||
background: rgba(255,255,255,0.92);
|
||
backdrop-filter: blur(16px);
|
||
border-bottom: 1px solid var(--qz-border);
|
||
}
|
||
|
||
.qz-nav-logo { font-size: 18px; font-weight: 700; color: #fff; text-decoration: none; }
|
||
.qz-nav-links { display: flex; gap: 24px; list-style: none; }
|
||
.qz-nav-links a { font-size: 14px; color: var(--qz-text-dim); text-decoration: none; transition: color 0.2s; }
|
||
.qz-nav-links a:hover { color: #fff; }
|
||
|
||
/* Buttons */
|
||
.qz-btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 12px 24px; border-radius: var(--qz-radius);
|
||
font-size: 15px; font-weight: 600; text-decoration: none;
|
||
transition: all 0.2s; cursor: pointer; border: none;
|
||
}
|
||
|
||
.qz-btn-primary {
|
||
background: var(--qz-blue); color: #fff;
|
||
}
|
||
.qz-btn-primary:hover {
|
||
background: var(--qz-blue-dark); transform: translateY(-1px);
|
||
}
|
||
|
||
.qz-btn-ghost {
|
||
background: transparent; color: var(--qz-text);
|
||
border: 1px solid var(--qz-border);
|
||
}
|
||
.qz-btn-ghost:hover {
|
||
border-color: var(--qz-blue); color: var(--qz-blue);
|
||
}
|
||
|
||
/* Cards */
|
||
.qz-card {
|
||
background: var(--qz-surface);
|
||
border: 1px solid var(--qz-border);
|
||
border-radius: var(--qz-radius);
|
||
padding: 24px;
|
||
transition: transform 0.2s, border-color 0.2s;
|
||
}
|
||
.qz-card:hover {
|
||
transform: translateY(-2px);
|
||
border-color: rgba(0,122,255,0.3);
|
||
}
|
||
|
||
/* Footer */
|
||
.qz-footer {
|
||
border-top: 1px solid var(--qz-border);
|
||
padding: 40px 24px;
|
||
text-align: center;
|
||
color: var(--qz-text-dim);
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Banner */
|
||
.qz-banner {
|
||
background: #001E50;
|
||
color: rgba(255,255,255,0.55);
|
||
text-align: center;
|
||
padding: 10px;
|
||
font-size: 12px;
|
||
}
|
||
.qz-banner a { color: rgba(255,255,255,0.8); text-decoration: none; }
|
||
|
||
@media (max-width: 768px) {
|
||
.qz-nav-links { display: none; }
|
||
}
|
||
</style>
|
||
<script src="i18n.js"></script>
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="quiXzoom — Mission Map">
|
||
<meta property="og:description" content="quiXzoom — Earn per Mission. No Platform Fee.">
|
||
<meta property="og:url" content="https://www.quixzoom.com/map.html">
|
||
<meta property="og:image" content="https://www.quixzoom.com/images/mission-map.jpg">
|
||
<meta property="og:image:width" content="1200">
|
||
<meta property="og:image:height" content="630">
|
||
<meta property="og:image:alt" content="quiXzoom mission map for field data collection">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:image" content="https://www.quixzoom.com/images/mission-map.jpg">
|
||
<meta name="twitter:title" content="quiXzoom — Mission Map">
|
||
<script type="module" src="https://auth.quixzoom.com/site-auth-snippet.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Loading overlay -->
|
||
<div id="loading-overlay">
|
||
<div class="spinner"></div>
|
||
<div id="loading-text">Laddar missioner…</div>
|
||
</div>
|
||
|
||
<!-- Map container -->
|
||
<div id="map"></div>
|
||
|
||
<!-- Header -->
|
||
<div id="header">
|
||
<div id="header-inner">
|
||
<div id="header-title">
|
||
<h1>quiXzoom · Missioner</h1>
|
||
<p id="last-updated">Uppdaterar…</p>
|
||
</div>
|
||
<button id="refresh-btn" title="Uppdatera" onclick="loadMissions(true)">
|
||
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4C7.58 4 4 7.58 4 12s3.58 8 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search/filter bar -->
|
||
<div id="search-bar">
|
||
<div id="search-input-wrap">
|
||
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||
<input type="text" id="search-input" placeholder="Sök uppdrag…" oninput="filterMissions()" />
|
||
</div>
|
||
<select id="cat-filter" onchange="filterMissions()">
|
||
<option value="">Alla kategorier</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Bottom sheet -->
|
||
<div id="bottom-sheet">
|
||
<div id="sheet-handle"></div>
|
||
<div id="stats-row">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Öppna uppdrag</div>
|
||
<div class="stat-value blue" id="stat-open">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total ersättning</div>
|
||
<div class="stat-value green" id="stat-reward">—</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Pågående</div>
|
||
<div class="stat-value orange" id="stat-assigned">—</div>
|
||
</div>
|
||
</div>
|
||
<div id="legend-row">
|
||
<div class="legend-item"><div class="legend-dot" style="background:#007AFF"></div>Öppen</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#FF9500"></div>Pågående</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#34C759"></div>Klar</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Config
|
||
const API_URL = '/zoomer/missions?limit=100';
|
||
const REFRESH_INTERVAL = 30000;
|
||
const SWEDEN_CENTER = [62.0, 16.5];
|
||
const INITIAL_ZOOM = 5;
|
||
|
||
const PIN_EMOJI = { open: '', assigned: '', completed: '<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>' };
|
||
const STATUS_COLOR = { open: '#007AFF', assigned: '#FF9500', completed: '#34C759' };
|
||
const STATUS_LABEL = { open: 'Öppen', assigned: 'Pågående', completed: 'Klar' };
|
||
|
||
// Mission safety levels
|
||
const RISK_LEVELS = {
|
||
L1: { label: 'L1 — Public Safe', hint: 'Alla verifierade Zoomers' },
|
||
L2: { label: 'L2 — Intermediate', hint: 'Erfarenhet + kvalitetshistorik krävs' },
|
||
L3: { label: 'L3 — Advanced', hint: 'Hög reputation + säkerhetsutbildning' },
|
||
L4: { label: 'L4 — Restricted', hint: 'Manuell granskning — teamuppdrag' }
|
||
};
|
||
|
||
function getRiskLevel(m) {
|
||
// Derive from reward or explicit field
|
||
if (m.risk_level) return m.risk_level;
|
||
const r = Number(m.reward_sek) || 0;
|
||
if (r >= 100) return 'L4';
|
||
if (r >= 60) return 'L3';
|
||
if (r >= 35) return 'L2';
|
||
return 'L1';
|
||
}
|
||
|
||
// State
|
||
let allMissions = [];
|
||
let markers = {};
|
||
let refreshTimer = null;
|
||
|
||
// Map Init
|
||
const map = L.map('map', {
|
||
center: SWEDEN_CENTER,
|
||
zoom: INITIAL_ZOOM,
|
||
zoomControl: true,
|
||
attributionControl: false
|
||
});
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
attribution: '© OpenStreetMap'
|
||
}).addTo(map);
|
||
|
||
// Subtle attribution
|
||
L.control.attribution({ position: 'bottomright', prefix: '© OSM' }).addTo(map);
|
||
|
||
// Marker creation
|
||
function createPinIcon(status) {
|
||
const color = STATUS_COLOR[status] || '#8E8E93';
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44" viewBox="0 0 36 44">
|
||
<filter id="s" x="-30%" y="-30%" width="160%" height="160%">
|
||
<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-color="rgba(0,0,0,0.25)"/>
|
||
</filter>
|
||
<g filter="url(#s)">
|
||
<circle cx="18" cy="18" r="15" fill="${color}" stroke="white" stroke-width="2.5"/>
|
||
<path d="M18 33 L18 44" stroke="${color}" stroke-width="2" opacity="0.5"/>
|
||
</g>
|
||
</svg>`;
|
||
return L.divIcon({
|
||
className: '',
|
||
html: svg,
|
||
iconSize: [36, 44],
|
||
iconAnchor: [18, 44],
|
||
popupAnchor: [0, -46]
|
||
});
|
||
}
|
||
|
||
function createPopupHTML(m) {
|
||
const statusClass = `status-${m.status}`;
|
||
const rewardStr = m.reward_sek ? `${Number(m.reward_sek).toLocaleString('sv-SE')} kr` : '—';
|
||
const catLabel = m.category ? m.category.replace(/_/g, ' ') : '—';
|
||
const clientStr = m.client || '—';
|
||
const rl = getRiskLevel(m);
|
||
return `
|
||
<div class="popup-card">
|
||
<div class="popup-category">${catLabel}</div>
|
||
<div class="popup-title">${escHtml(m.title)}</div>
|
||
<div class="popup-meta">
|
||
<div class="popup-meta-item">
|
||
<div class="popup-meta-label">Ersättning</div>
|
||
<div class="popup-meta-value popup-reward">${rewardStr}</div>
|
||
</div>
|
||
<div class="popup-meta-item">
|
||
<div class="popup-meta-label">Uppdragsgivare</div>
|
||
<div class="popup-meta-value">${escHtml(clientStr)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="popup-status-badge ${statusClass}">
|
||
<span></span> ${STATUS_LABEL[m.status] || m.status}
|
||
</div>
|
||
<div class="popup-risk">
|
||
<span class="risk-badge risk-${rl}">${RISK_LEVELS[rl].label}</span>
|
||
<span class="risk-hint">${RISK_LEVELS[rl].hint}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// Load Missions
|
||
async function loadMissions(manual = false) {
|
||
const btn = document.getElementById('refresh-btn');
|
||
btn.classList.add('loading');
|
||
try {
|
||
// Try internal API first, fallback to zoomer service
|
||
let data;
|
||
try {
|
||
const r = await fetch('/zoomer/missions?limit=100');
|
||
if (!r.ok) throw new Error('not ok');
|
||
data = await r.json();
|
||
} catch {
|
||
const r = await fetch('http://localhost:3304/missions?limit=100');
|
||
data = await r.json();
|
||
}
|
||
|
||
const missions = Array.isArray(data) ? data : (data.missions || []);
|
||
allMissions = missions;
|
||
renderCategories();
|
||
filterMissions();
|
||
updateStats();
|
||
document.getElementById('last-updated').textContent =
|
||
`Uppdaterad ${new Date().toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||
|
||
// Hide loading overlay
|
||
document.getElementById('loading-overlay').classList.add('hidden');
|
||
} catch (e) {
|
||
console.error('Failed to load missions:', e);
|
||
document.getElementById('loading-text').textContent = 'Kunde inte ladda missioner <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>';
|
||
} finally {
|
||
btn.classList.remove('loading');
|
||
// Schedule next refresh
|
||
clearTimeout(refreshTimer);
|
||
refreshTimer = setTimeout(() => loadMissions(false), REFRESH_INTERVAL);
|
||
}
|
||
}
|
||
|
||
function renderCategories() {
|
||
const sel = document.getElementById('cat-filter');
|
||
const cats = [...new Set(allMissions.map(m => m.category).filter(Boolean))].sort();
|
||
const current = sel.value;
|
||
sel.innerHTML = '<option value="">Alla kategorier</option>';
|
||
cats.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c;
|
||
opt.textContent = c.replace(/_/g, ' ');
|
||
if (c === current) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function filterMissions() {
|
||
const query = document.getElementById('search-input').value.toLowerCase();
|
||
const cat = document.getElementById('cat-filter').value;
|
||
|
||
// Remove all existing markers
|
||
Object.values(markers).forEach(m => map.removeLayer(m));
|
||
markers = {};
|
||
|
||
const visible = allMissions.filter(m => {
|
||
if (cat && m.category !== cat) return false;
|
||
if (query && !m.title.toLowerCase().includes(query) && !(m.client||'').toLowerCase().includes(query)) return false;
|
||
return true;
|
||
});
|
||
|
||
visible.forEach(m => {
|
||
if (!m.lat || !m.lng) return;
|
||
const marker = L.marker([m.lat, m.lng], { icon: createPinIcon(m.status) });
|
||
marker.bindPopup(createPopupHTML(m), { maxWidth: 280, minWidth: 260 });
|
||
marker.addTo(map);
|
||
markers[m.id] = marker;
|
||
});
|
||
}
|
||
|
||
function updateStats() {
|
||
const open = allMissions.filter(m => m.status === 'open');
|
||
const assigned = allMissions.filter(m => m.status === 'assigned');
|
||
const totalReward = open.reduce((s, m) => s + (Number(m.reward_sek) || 0), 0);
|
||
document.getElementById('stat-open').textContent = open.length;
|
||
document.getElementById('stat-assigned').textContent = assigned.length;
|
||
document.getElementById('stat-reward').textContent =
|
||
totalReward >= 1000
|
||
? `${(totalReward/1000).toFixed(1)}k`
|
||
: `${totalReward}`;
|
||
}
|
||
|
||
// Urban Intelligence Layer
|
||
|
||
// Demo zones — production: fetch from /api/urban-intelligence/zones
|
||
const UI_ZONES = [
|
||
{ id:'sthlm-inner', name:'Stockholm Innerstad', assessment:'green', confidence:96,
|
||
scores:{infrastructure:9.1,safety:8.7,traffic:6.2,night:7.8},
|
||
contradiction:'low', contradictionText:'Observerad miljö stämmer väl överens med officiella data.',
|
||
coords:[[59.340,18.040],[59.340,18.100],[59.310,18.100],[59.310,18.040]] },
|
||
{ id:'sthlm-rinkeby', name:'Rinkeby–Kista', assessment:'orange', confidence:81,
|
||
scores:{infrastructure:6.2,safety:5.1,traffic:7.1,night:4.2},
|
||
contradiction:'high', contradictionText:'Avvikelse: officiell data anger medelhög risk, men observationer visar 41% trasig gatubelysning, hög klotterförekomst och låg kvällsaktivitet.',
|
||
coords:[[59.390,17.910],[59.390,17.970],[59.360,17.970],[59.360,17.910]] },
|
||
{ id:'gbg-inner', name:'Göteborg Centrum', assessment:'yellow', confidence:88,
|
||
scores:{infrastructure:7.8,safety:7.2,traffic:5.9,night:6.5},
|
||
contradiction:'medium', contradictionText:'Viss avvikelse: trafikrisk underrapporterad i officiell data jämfört med observerad trängsel.',
|
||
coords:[[57.720,11.950],[57.720,12.000],[57.695,12.000],[57.695,11.950]] },
|
||
{ id:'malmo-inner', name:'Malmö Centrum', assessment:'yellow', confidence:79,
|
||
scores:{infrastructure:7.5,safety:6.4,traffic:6.8,night:5.9},
|
||
contradiction:'medium', contradictionText:'Nattrisk avviker från officiell statistik. Fler observationer behövs.',
|
||
coords:[[55.615,13.000],[55.615,13.030],[55.595,13.030],[55.595,13.000]] }
|
||
];
|
||
|
||
const ASSESS_STYLE = {
|
||
green: {color:'#34C759',fillOpacity:0.13,weight:1.5,dash:null},
|
||
yellow: {color:'#FFCC00',fillOpacity:0.16,weight:1.5,dash:'5 4'},
|
||
orange: {color:'#FF9500',fillOpacity:0.20,weight:2, dash:'5 4'},
|
||
red: {color:'#FF3B30',fillOpacity:0.25,weight:2, dash:'4 3'},
|
||
darkred: {color:'#8B0000',fillOpacity:0.30,weight:2.5,dash:'4 3'}
|
||
};
|
||
|
||
const C_LABELS = {low:'Contradiction Score: Låg',medium:'Contradiction Score: Medel',high:'Contradiction Score: HÖG <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>'};
|
||
|
||
let uiGroup = L.layerGroup().addTo(map);
|
||
let uiOn = true;
|
||
|
||
function scoreBar(v) {
|
||
const pct = Math.round(v*10);
|
||
const col = v>=7.5?'#34C759':v>=5?'#FFCC00':'#FF3B30';
|
||
return `<div class="ui-score-bar"><div class="ui-score-fill" style="width:${pct}%;background:${col}"></div></div>`;
|
||
}
|
||
|
||
function uiPopup(z) {
|
||
const s = z.scores;
|
||
return `<div class="ui-popup">
|
||
<div class="ui-popup-header"><span class="ui-popup-title">${z.name}</span><span class="ui-confidence">Confidence ${z.confidence}%</span></div>
|
||
<div class="ui-scores">
|
||
<div class="ui-score-item"><div class="ui-score-label">Infrastruktur</div>${scoreBar(s.infrastructure)}<div class="ui-score-val">${s.infrastructure}/10</div></div>
|
||
<div class="ui-score-item"><div class="ui-score-label">Trygghet</div>${scoreBar(s.safety)}<div class="ui-score-val">${s.safety}/10</div></div>
|
||
<div class="ui-score-item"><div class="ui-score-label">Trafikrisk</div>${scoreBar(s.traffic)}<div class="ui-score-val">${s.traffic}/10</div></div>
|
||
<div class="ui-score-item"><div class="ui-score-label">Nattrisk</div>${scoreBar(s.night)}<div class="ui-score-val">${s.night}/10</div></div>
|
||
</div>
|
||
<div class="ui-contradiction ${z.contradiction}"><span class="ui-contradiction-label">${C_LABELS[z.contradiction]}</span>${z.contradictionText}</div>
|
||
<div class="ui-assessment">Landvex Assessment — baserat på officiella källor + quiXzoom-observationer. Ej ett faktapåstående om faktiska förhållanden.</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderUI() {
|
||
uiGroup.clearLayers();
|
||
if (!uiOn) return;
|
||
UI_ZONES.forEach(z => {
|
||
const st = ASSESS_STYLE[z.assessment];
|
||
L.polygon(z.coords, {
|
||
color:st.color, fillColor:st.color,
|
||
fillOpacity:st.fillOpacity, weight:st.weight,
|
||
dashArray:st.dash
|
||
}).bindPopup(uiPopup(z), {maxWidth:300,minWidth:240}).addTo(uiGroup);
|
||
});
|
||
}
|
||
|
||
renderUI();
|
||
|
||
// Toggle button
|
||
const tog = document.createElement('div');
|
||
tog.id = 'ui-toggle';
|
||
tog.innerHTML = '<div id="ui-dot"></div><span>Urban Intelligence</span>';
|
||
tog.onclick = () => { uiOn=!uiOn; tog.classList.toggle('off',!uiOn); renderUI(); };
|
||
document.body.appendChild(tog);
|
||
|
||
// Start
|
||
loadMissions();
|
||
</script>
|
||
</body>
|
||
</html>
|