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
912 lines
32 KiB
HTML
912 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="sv">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<link rel="canonical" href="https://www.quixzoom.com/mission-map.html">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>quiXzoom — Live Mission Map</title>
|
|
<meta name="description" content="Explore the quiXzoom Live Mission Map — discover available photo missions near you and start earning today.">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--surface: #111118;
|
|
--border: rgba(255,255,255,0.08);
|
|
--accent: #ff3b30;
|
|
--accent2: #34c759;
|
|
--text: #f5f5f7;
|
|
--text2: #8e8e93;
|
|
--font: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif;
|
|
}
|
|
body { background: var(--bg); color: var(--text); font-family: var(--font); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 12px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0; z-index: 1000;
|
|
}
|
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
|
.logo { font-size: 18px; font-weight: 700; letter-spacing: -0.5px; }
|
|
.logo span { color: var(--accent); }
|
|
.badge-live { background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
|
|
padding: 2px 6px; border-radius: var(--radius-sm); letter-spacing: 0.5px; animation: pulse 2s infinite; }
|
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
|
|
|
|
/* Stats bar */
|
|
.stats-bar {
|
|
display: flex; gap: 0; background: var(--surface); border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
.stat {
|
|
flex: 1; padding: 10px 16px; border-right: 1px solid var(--border); text-align: center;
|
|
}
|
|
.stat:last-child { border-right: none; }
|
|
.stat-val { font-size: 22px; font-weight: 700; line-height: 1; }
|
|
.stat-val.red { color: var(--accent); }
|
|
.stat-val.green { color: var(--accent2); }
|
|
.stat-label { font-size: 11px; color: var(--text2); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
|
|
/* Filter bar */
|
|
.filter-bar {
|
|
display: flex; gap: 8px; padding: 8px 16px; background: var(--bg); border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
.filter-btn {
|
|
background: var(--surface); color: var(--text2); border: 1px solid var(--border);
|
|
border-radius: var(--radius-md); padding: 5px 14px; font-size: 13px; cursor: pointer; transition: all 0.15s;
|
|
font-family: var(--font);
|
|
}
|
|
.filter-btn:hover { border-color: rgba(255,255,255,0.2); color: var(--text); }
|
|
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
|
|
/* Map */
|
|
#map { flex: 1; }
|
|
|
|
/* Popup styling */
|
|
.leaflet-popup-content-wrapper { background: #F2F2F7; border: 1px solid rgba(255,255,255,0.12); border-radius: var(--radius-md); box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
|
.leaflet-popup-tip { background: #F2F2F7; }
|
|
.leaflet-popup-content { color: #1c1c1e; margin: 0; padding: 0; }
|
|
.popup { padding: 14px; min-width: 220px; }
|
|
.popup-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
|
|
.popup-row { display: flex; justify-content: space-between; font-size: 12px; color: #555; margin-bottom: 4px; }
|
|
.popup-row span:last-child { color: #f5f5f7; }
|
|
.popup-badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 10px; font-weight: 600; text-transform: uppercase; margin-top: 8px; }
|
|
.badge-urgent { background: rgba(255,59,48,0.2); color: #ff3b30; }
|
|
.badge-normal { background: rgba(52,199,89,0.15); color: #34c759; }
|
|
.badge-pending { background: rgba(255,159,10,0.15); color: #ff9f0a; }
|
|
|
|
/* Status dot */
|
|
.status-dot { width: 8px; height: 8px; border-radius: var(--radius-full); display: inline-block; margin-right: 5px; }
|
|
.dot-active { background: var(--accent2); animation: glow 1.5s infinite; }
|
|
.dot-pending { background: #ff9f0a; }
|
|
@keyframes glow { 0%,100%{box-shadow:0 0 0 0 rgba(52,199,89,0.4)} 50%{box-shadow:0 0 0 4px rgba(52,199,89,0)} }
|
|
|
|
/* Connection status */
|
|
.conn-badge { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-sm); }
|
|
.conn-connected { background: rgba(52,199,89,0.2); color: var(--accent2); }
|
|
.conn-disconnected { background: rgba(255,59,48,0.2); color: var(--accent); }
|
|
.conn-connecting { background: rgba(255,159,10,0.2); color: #ff9f0a; }
|
|
@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>
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:title" content="quiXzoom — Live Mission Map">
|
|
<meta property="og:description" content="quiXzoom — Earn per Mission. No Platform Fee.">
|
|
<meta property="og:url" content="https://www.quixzoom.com/mission-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 real-time mission map showing active photo missions">
|
|
<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 — Live Mission Map">
|
|
<script type="module" src="https://auth.quixzoom.com/site-auth-snippet.js"></script>
|
|
<link rel="alternate" hreflang="en" href="https://www.quixzoom.com/" />
|
|
<link rel="alternate" hreflang="sv" href="https://www.quixzoom.com/sv/" />
|
|
<link rel="alternate" hreflang="de" href="https://www.quixzoom.com/de/" />
|
|
<link rel="alternate" hreflang="fr" href="https://www.quixzoom.com/fr/" />
|
|
<link rel="alternate" hreflang="es" href="https://www.quixzoom.com/es/" />
|
|
<link rel="alternate" hreflang="it" href="https://www.quixzoom.com/it/" />
|
|
<link rel="alternate" hreflang="nl" href="https://www.quixzoom.com/nl/" />
|
|
<link rel="alternate" hreflang="x-default" href="https://www.quixzoom.com/" />
|
|
</head>
|
|
<body>
|
|
<h1 style="position:absolute;left:-9999px;">Mission Map — quiXzoom</h1>
|
|
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="logo">qui<span>X</span>zoom</div>
|
|
<span class="badge-live"> LIVE</span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<span id="conn-status" class="conn-badge conn-connecting">Ansluter…</span>
|
|
<span style="font-size:12px;color:var(--text2);">Mission Control</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-bar">
|
|
<div class="stat">
|
|
<div class="stat-val red" id="stat-active">—</div>
|
|
<div class="stat-label">Aktiva uppdrag</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-val green" id="stat-zoomers">—</div>
|
|
<div class="stat-label">Zoomers online</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-val" id="stat-completed">—</div>
|
|
<div class="stat-label">Klara idag</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-val" id="stat-clients">—</div>
|
|
<div class="stat-label">Live-anslutna</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-bar">
|
|
<button class="filter-btn active" onclick="setFilter('all')">Alla</button>
|
|
<button class="filter-btn" onclick="setFilter('active')">Aktiva</button>
|
|
<button class="filter-btn" onclick="setFilter('pending')">Väntande</button>
|
|
<button class="filter-btn" onclick="setFilter('completed')">Klara</button>
|
|
</div>
|
|
|
|
<div id="map"></div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
// Map init
|
|
const map = L.map('map', {
|
|
center: [7.9519, 98.2849],
|
|
zoom: 12,
|
|
zoomControl: true,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OpenStreetMap © CARTO',
|
|
subdomains: 'abcd',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
// State
|
|
let missionMarkers = {};
|
|
let zoomerMarkers = {};
|
|
let currentFilter = 'all';
|
|
let currentMissions = [];
|
|
let currentZoomers = [];
|
|
let clientCount = 0;
|
|
|
|
// Icons
|
|
function missionIcon(priority, status) {
|
|
const color = status === 'urgent' || priority === 'urgent' ? '#ff3b30' : status === 'completed' ? '#636366' : '#ff9f0a';
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
|
|
<path d="M16 0C7.16 0 0 7.16 0 16c0 12 16 24 16 24s16-12 16-24C32 7.16 24.84 0 16 0z" fill="${color}" opacity="0.95"/>
|
|
<text x="16" y="21" text-anchor="middle" font-size="14" fill="white"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg></text>
|
|
</svg>`;
|
|
return L.divIcon({ html: svg, iconSize: [32, 40], iconAnchor: [16, 40], popupAnchor: [0, -40], className: '' });
|
|
}
|
|
|
|
function zoomerIcon(name) {
|
|
const initials = name.split(' ').map(p => p[0]).join('');
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
|
|
<circle cx="14" cy="14" r="13" fill="#34c759" stroke="#fff" stroke-width="2"/>
|
|
<circle cx="14" cy="14" r="16" fill="rgba(52,199,89,0.3)" class="pulse-ring">
|
|
<animate attributeName="r" from="12" to="20" dur="1.5s" repeatCount="indefinite"/>
|
|
<animate attributeName="opacity" from="0.4" to="0" dur="1.5s" repeatCount="indefinite"/>
|
|
</circle>
|
|
<text x="14" y="19" text-anchor="middle" font-size="10" font-weight="700" fill="white">${initials}</text>
|
|
</svg>`;
|
|
return L.divIcon({ html: svg, iconSize: [28, 28], iconAnchor: [14, 14], popupAnchor: [0, -14], className: '' });
|
|
}
|
|
|
|
// Filter
|
|
function setFilter(filter) {
|
|
currentFilter = filter;
|
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
renderMissions(currentMissions);
|
|
}
|
|
|
|
// Render missions
|
|
function renderMissions(missions) {
|
|
currentMissions = missions;
|
|
// Remove old markers
|
|
Object.values(missionMarkers).forEach(m => m.remove());
|
|
missionMarkers = {};
|
|
|
|
const filtered = currentFilter === 'all' ? missions : missions.filter(m => m.status === currentFilter);
|
|
|
|
filtered.forEach(mission => {
|
|
const icon = missionIcon(mission.priority, mission.status);
|
|
const marker = L.marker([mission.lat, mission.lng], { icon }).addTo(map);
|
|
|
|
const priorityBadge = mission.priority === 'urgent'
|
|
? '<span class="popup-badge badge-urgent"> Urgent</span>'
|
|
: mission.status === 'pending'
|
|
? '<span class="popup-badge badge-pending">⏳ Väntande</span>'
|
|
: '<span class="popup-badge badge-normal"><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> Aktiv</span>';
|
|
|
|
marker.bindPopup(`
|
|
<div class="popup">
|
|
<div class="popup-title"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg> ${mission.title}</div>
|
|
<div class="popup-row"><span>Kund</span><span>${mission.customer}</span></div>
|
|
<div class="popup-row"><span>Typ</span><span>${mission.type}</span></div>
|
|
<div class="popup-row"><span>Status</span><span><span class="status-dot dot-${mission.status === 'active' ? 'active' : 'pending'}"></span>${mission.status}</span></div>
|
|
${mission.zoomerId ? `<div class="popup-row"><span>Zoomer</span><span>${mission.zoomerId}</span></div>` : ''}
|
|
${priorityBadge}
|
|
</div>
|
|
`, { maxWidth: 280 });
|
|
|
|
missionMarkers[mission.id] = marker;
|
|
});
|
|
}
|
|
|
|
// Render zoomers
|
|
function renderZoomers(zoomers) {
|
|
currentZoomers = zoomers;
|
|
// Move existing or create new
|
|
zoomers.forEach(z => {
|
|
if (zoomerMarkers[z.id]) {
|
|
zoomerMarkers[z.id].setLatLng([z.lat, z.lng]);
|
|
} else {
|
|
const icon = zoomerIcon(z.name);
|
|
const marker = L.marker([z.lat, z.lng], { icon }).addTo(map);
|
|
marker.bindPopup(`
|
|
<div class="popup">
|
|
<div class="popup-title">🟢 ${z.name}</div>
|
|
<div class="popup-row"><span>ID</span><span>${z.id}</span></div>
|
|
<div class="popup-row"><span>Betyg</span><span> ${z.rating}</span></div>
|
|
<div class="popup-row"><span>Status</span><span>Online</span></div>
|
|
</div>
|
|
`, { maxWidth: 220 });
|
|
zoomerMarkers[z.id] = marker;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Stats
|
|
function updateStats(stats) {
|
|
document.getElementById('stat-active').textContent = stats.active_missions ?? '—';
|
|
document.getElementById('stat-zoomers').textContent = stats.zoomers_online ?? '—';
|
|
document.getElementById('stat-completed').textContent = stats.completed_today ?? '—';
|
|
clientCount++;
|
|
document.getElementById('stat-clients').textContent = clientCount;
|
|
}
|
|
|
|
// WebSocket
|
|
let ws;
|
|
let reconnectTimer;
|
|
|
|
function connect() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${proto}//${location.host}/ws/missions`;
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
document.getElementById('conn-status').textContent = ' Ansluten';
|
|
document.getElementById('conn-status').className = 'conn-badge conn-connected';
|
|
clearTimeout(reconnectTimer);
|
|
};
|
|
|
|
ws.onmessage = (evt) => {
|
|
const msg = JSON.parse(evt.data);
|
|
if (msg.type === 'mission_update') {
|
|
renderMissions(msg.data.missions || []);
|
|
renderZoomers(msg.data.zoomers || []);
|
|
updateStats(msg.data.stats || {});
|
|
} else if (msg.type === 'zoomer_move') {
|
|
const z = msg.data;
|
|
if (zoomerMarkers[z.zoomerId]) {
|
|
zoomerMarkers[z.zoomerId].setLatLng([z.lat, z.lng]);
|
|
}
|
|
} else if (msg.type === 'mission_complete') {
|
|
const d = msg.data;
|
|
if (missionMarkers[d.missionId]) {
|
|
missionMarkers[d.missionId].remove();
|
|
delete missionMarkers[d.missionId];
|
|
}
|
|
// Toast
|
|
showToast(`<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> Uppdrag klart: ${d.title}`);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
document.getElementById('conn-status').textContent = ' Frånkopplad';
|
|
document.getElementById('conn-status').className = 'conn-badge conn-disconnected';
|
|
reconnectTimer = setTimeout(() => {
|
|
document.getElementById('conn-status').textContent = '… Återansluter';
|
|
document.getElementById('conn-status').className = 'conn-badge conn-connecting';
|
|
connect();
|
|
}, 3000);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// Fallback to REST polling if WS unavailable
|
|
fallbackPolling();
|
|
};
|
|
}
|
|
|
|
// Fallback polling (if WS not available)
|
|
let pollInterval;
|
|
function fallbackPolling() {
|
|
if (pollInterval) return;
|
|
document.getElementById('conn-status').textContent = '↻ Polling';
|
|
document.getElementById('conn-status').className = 'conn-badge conn-connecting';
|
|
pollInterval = setInterval(async () => {
|
|
try {
|
|
const r = await fetch('https://api.quixzoom.com/api/qz/missions');
|
|
const d = await r.json();
|
|
if (d.ok) {
|
|
renderMissions(d.missions);
|
|
renderZoomers(d.zoomers || []);
|
|
updateStats(d.stats);
|
|
}
|
|
} catch(e) {}
|
|
}, 3000);
|
|
}
|
|
|
|
// Toast notification
|
|
function showToast(msg) {
|
|
const t = document.createElement('div');
|
|
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1c1c1e;color:#f5f5f7;border:1px solid rgba(255,255,255,0.12);border-radius: var(--radius-md);padding:10px 20px;font-size:14px;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 4000);
|
|
}
|
|
|
|
// Check URL params for mission focus
|
|
const urlParams = new URLSearchParams(location.search);
|
|
const focusMission = urlParams.get('mission');
|
|
|
|
// Start
|
|
connect();
|
|
</script>
|
|
<!-- quiXzoom Mina Sidor - Universal Header Component -->
|
|
<!-- Injicera detta i <head> på alla quiXzoom-sajter -->
|
|
|
|
<style>
|
|
.qz-auth-bar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 48px;
|
|
background: #0A0A0A;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 20px;
|
|
z-index: 9999;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
.qz-auth-bar .qz-logo {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
text-decoration: none;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
.qz-auth-bar .qz-logo span {
|
|
color: #007AFF;
|
|
}
|
|
.qz-auth-bar .qz-nav {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.qz-auth-bar .qz-btn {
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
border: none;
|
|
text-decoration: none;
|
|
}
|
|
.qz-auth-bar .qz-btn-primary {
|
|
background: #007AFF;
|
|
color: white;
|
|
}
|
|
.qz-auth-bar .qz-btn-primary:hover {
|
|
background: #0056CC;
|
|
}
|
|
.qz-auth-bar .qz-btn-ghost {
|
|
background: transparent;
|
|
color: rgba(255,255,255,0.8);
|
|
}
|
|
.qz-auth-bar .qz-btn-ghost:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
color: white;
|
|
}
|
|
.qz-auth-bar .qz-user {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: white;
|
|
font-size: 14px;
|
|
}
|
|
.qz-auth-bar .qz-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: #007AFF;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
.qz-auth-bar .qz-dropdown {
|
|
position: relative;
|
|
}
|
|
.qz-auth-bar .qz-dropdown-menu {
|
|
position: absolute;
|
|
top: 40px;
|
|
right: 0;
|
|
background: #1C1C1E;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
padding: 8px;
|
|
min-width: 200px;
|
|
display: none;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
}
|
|
.qz-auth-bar .qz-dropdown-menu.active {
|
|
display: block;
|
|
}
|
|
.qz-auth-bar .qz-dropdown-item {
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
color: white;
|
|
text-decoration: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 14px;
|
|
transition: background 0.2s;
|
|
}
|
|
.qz-auth-bar .qz-dropdown-item:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.qz-auth-bar .qz-dropdown-divider {
|
|
height: 1px;
|
|
background: rgba(255,255,255,0.1);
|
|
margin: 8px 0;
|
|
}
|
|
.qz-auth-bar .qz-balance {
|
|
background: rgba(0,122,255,0.15);
|
|
color: #007AFF;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.qz-auth-bar .qz-nav .qz-btn-ghost:not(.qz-user) {
|
|
display: none;
|
|
}
|
|
}
|
|
/* 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>
|
|
|
|
<div id="qz-auth-bar" class="qz-auth-bar">
|
|
<a href="https://quixzoom.com" class="qz-logo">qui<span>X</span>zoom</a>
|
|
<div class="qz-nav">
|
|
<div id="qz-auth-guest">
|
|
<a href="https://app.quixzoom.com/login" class="qz-btn qz-btn-ghost">Logga in</a>
|
|
<a href="https://app.quixzoom.com/register" class="qz-btn qz-btn-primary">Bli Zoomer</a>
|
|
</div>
|
|
<div id="qz-auth-user" style="display:none;">
|
|
<span class="qz-balance" id="qz-balance">$0.00</span>
|
|
<div class="qz-dropdown">
|
|
<button class="qz-btn qz-btn-ghost qz-user" onclick="qzToggleDropdown()">
|
|
<div class="qz-avatar" id="qz-avatar">E</div>
|
|
<span id="qz-user-name">Erik</span>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
<div class="qz-dropdown-menu" id="qz-dropdown-menu">
|
|
<a href="https://app.quixzoom.com/dashboard" class="qz-dropdown-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
|
Dashboard
|
|
</a>
|
|
<a href="https://app.quixzoom.com/wallet" class="qz-dropdown-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/></svg>
|
|
Plånbok
|
|
</a>
|
|
<a href="https://app.quixzoom.com/missions" class="qz-dropdown-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
|
Mina uppdrag
|
|
</a>
|
|
<div class="qz-dropdown-divider"></div>
|
|
<a href="https://app.quixzoom.com/settings" class="qz-dropdown-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.67 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.67 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.67a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
Inställningar
|
|
</a>
|
|
<a href="#" onclick="qzLogout(); return false;" class="qz-dropdown-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
Logga ut
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const SSO_BASE = 'https://auth.quixzoom.com';
|
|
const APP_BASE = 'https://app.quixzoom.com';
|
|
|
|
// Check auth status
|
|
async function qzCheckAuth() {
|
|
try {
|
|
const res = await fetch(SSO_BASE + '/auth/check', {
|
|
credentials: 'include',
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.authenticated) {
|
|
document.getElementById('qz-auth-guest').style.display = 'none';
|
|
document.getElementById('qz-auth-user').style.display = 'flex';
|
|
document.getElementById('qz-user-name').textContent = data.user.name || data.user.email;
|
|
document.getElementById('qz-avatar').textContent = (data.user.name || data.user.email)[0].toUpperCase();
|
|
if (data.user.balance !== undefined) {
|
|
document.getElementById('qz-balance').textContent = '$' + data.user.balance.toFixed(2);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Silent fail - show guest state
|
|
}
|
|
}
|
|
|
|
// Toggle dropdown
|
|
window.qzToggleDropdown = function() {
|
|
document.getElementById('qz-dropdown-menu').classList.toggle('active');
|
|
};
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.qz-dropdown')) {
|
|
document.getElementById('qz-dropdown-menu').classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Logout
|
|
window.qzLogout = async function() {
|
|
try {
|
|
await fetch(SSO_BASE + '/auth/logout', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
} catch (e) {}
|
|
document.cookie = 'qz_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
|
|
document.cookie = 'qz_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
|
|
window.location.reload();
|
|
};
|
|
|
|
// Init
|
|
qzCheckAuth();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|