Files
boc/zoomer-app.html
T

1169 lines
57 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="sv" data-theme="dark">
<head>
<meta charset="UTF-8">
<link rel="canonical" href="https://www.quixzoom.com/zoomer-app.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#000000">
<title>Zoomer App — quiXzoom</title>
<meta name="description" content="Find missions near you, capture photos, and earn money with the quiXzoom Zoomer App. Flexible field work on your terms.">
<link rel="canonical" href="https://www.quixzoom.com/zoomer-app.html">
<link rel="stylesheet" href="/_design-system/apple-native/tokens.css">
<link rel="stylesheet" href="/vendor/leaflet.css">
<script src="/vendor/leaflet.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-text);
background: var(--background-primary);
color: var(--label-primary);
min-height: 100dvh;
padding-top: env(safe-area-inset-top, 44px);
padding-bottom: calc(env(safe-area-inset-bottom, 34px) + 83px);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* ── Tab system ── */
.tab-page { display: none; }
.tab-page.active { display: block; }
/* ── Nav Bar ── */
.nav-bar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 16px;
background: var(--navbar-bg, rgba(28,28,30,0.94));
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--separator);
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-family: var(--font-display); font-size: 17px; font-weight: 600;
letter-spacing: -0.41px;
}
.nav-logo {
background: linear-gradient(135deg, #0A84FF 0%, #5E5CE6 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
font-size: 18px; font-weight: 800;
}
.nav-btn {
background: none; border: none; color: var(--ios-blue);
font-family: var(--font-text); font-size: 17px; cursor: pointer; padding: 4px 0;
}
.nav-btn-sm {
background: none; border: none; cursor: pointer;
font-size: 20px; padding: 4px;
}
/* ── Tab Bar ── */
.tab-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 300;
display: flex;
background: rgba(28,28,30,0.94);
backdrop-filter: saturate(180%) blur(30px);
-webkit-backdrop-filter: saturate(180%) blur(30px);
border-top: 0.5px solid var(--separator);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.tab-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 8px 4px 4px;
cursor: pointer; text-decoration: none; border: none;
background: none; transition: opacity 0.15s;
}
.tab-item:active { opacity: 0.6; }
.tab-icon { font-size: 24px; line-height: 1; }
.tab-label { font-size: 10px; font-weight: 500; color: var(--label-tertiary); margin-top: 2px; letter-spacing: -0.1px; }
.tab-item.active .tab-label { color: #0A84FF; }
.tab-badge-wrap { position: relative; display: inline-block; }
.tab-dot {
position: absolute; top: -2px; right: -4px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--ios-red); border: 1.5px solid var(--background-primary);
display: none;
}
.tab-dot.show { display: block; }
/* ── Segment ── */
.segment-wrap { padding: 8px 16px; }
.segment { display: flex; background: var(--fill-tertiary); border-radius: 9px; padding: 2px; }
.seg-btn {
flex: 1; padding: 6px 0; background: none; border: none;
font-family: var(--font-text); font-size: 13px; font-weight: 500;
color: var(--label-secondary); cursor: pointer; border-radius: 7px; transition: all 0.2s;
}
.seg-btn.active {
background: var(--background-secondary); color: var(--label-primary); font-weight: 600;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* ── Section header ── */
.section-hdr {
padding: 20px 16px 8px;
font-size: 13px; font-weight: 500;
color: var(--label-secondary); text-transform: uppercase; letter-spacing: 0.5px;
}
/* ══════════════════════════════════
MAP TAB — Karta
══════════════════════════════════ */
#map-full {
width: 100%; height: calc(100dvh - env(safe-area-inset-top, 44px) - 44px - 83px - env(safe-area-inset-bottom, 34px));
min-height: 400px;
}
.map-filter-bar {
padding: 8px 12px; display: flex; gap: 8px; overflow-x: auto;
scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.map-filter-bar::-webkit-scrollbar { display: none; }
.filter-chip {
flex-shrink: 0; padding: 6px 14px; border-radius: 20px; border: 1.5px solid var(--separator);
background: var(--fill-tertiary); color: var(--label-secondary);
font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; transition: all 0.2s;
}
.filter-chip.active { border-color: #0A84FF; background: rgba(10,132,255,0.12); color: #0A84FF; }
.map-legend {
position: absolute; bottom: calc(90px + env(safe-area-inset-bottom, 34px) + 8px);
right: 12px; z-index: 500;
background: rgba(28,28,30,0.88); backdrop-filter: blur(20px);
border-radius: 12px; padding: 10px 12px;
border: 0.5px solid var(--separator);
font-size: 11px;
}
.legend-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; color: var(--label-secondary); }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* Mission bottom-sheet */
.mission-sheet {
position: fixed; left: 0; right: 0; bottom: calc(83px + env(safe-area-inset-bottom, 34px));
z-index: 400; padding: 0 12px 12px;
transform: translateY(200px); opacity: 0;
transition: all 0.35s cubic-bezier(0.32, 0.72, 0, 1);
pointer-events: none;
}
.mission-sheet.show { transform: translateY(0); opacity: 1; pointer-events: all; }
.sheet-card {
background: rgba(28,28,30,0.96); backdrop-filter: blur(40px);
border-radius: 18px; padding: 16px;
border: 0.5px solid var(--separator);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.sheet-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; }
.sheet-title { font-size: 17px; font-weight: 700; letter-spacing: -0.4px; flex: 1; padding-right: 8px; }
.sheet-close { background: var(--fill-secondary); border: none; border-radius: 50%; width: 28px; height: 28px; cursor: pointer; font-size: 14px; color: var(--label-secondary); flex-shrink: 0; }
.sheet-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.sheet-chip { padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.chip-reward { background: rgba(52,199,89,0.15); color: var(--ios-green); }
.chip-city { background: rgba(10,132,255,0.12); color: #0A84FF; }
.chip-type { background: var(--fill-tertiary); color: var(--label-secondary); }
.chip-dead { background: rgba(255,149,0,0.12); color: var(--ios-orange); }
.sheet-desc { font-size: 14px; color: var(--label-secondary); line-height: 1.5; margin-bottom: 14px; }
.sheet-actions { display: flex; gap: 10px; }
.sheet-btn {
flex: 1; padding: 13px; border-radius: 12px; border: none;
font-family: var(--font-text); font-size: 15px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
.sheet-btn:active { opacity: 0.7; }
.btn-primary { background: #0A84FF; color: #fff; }
.btn-secondary { background: var(--fill-secondary); color: var(--label-primary); }
/* ══════════════════════════════════
MINA UPPDRAG TAB
══════════════════════════════════ */
.missions-list { padding: 0 16px; display: flex; flex-direction: column; gap: 12px; }
.mission-card {
background: var(--background-secondary); border-radius: 16px; overflow: hidden;
border: 0.5px solid var(--separator);
cursor: pointer; transition: transform 0.12s, opacity 0.12s;
}
.mission-card:active { transform: scale(0.98); opacity: 0.85; }
.mc-top {
display: flex; align-items: flex-start; gap: 12px; padding: 14px 14px 10px;
}
.mc-icon {
width: 46px; height: 46px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
.mc-body { flex: 1; min-width: 0; }
.mc-title {
font-size: 16px; font-weight: 600; letter-spacing: -0.32px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mc-loc { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.mc-reward { font-size: 22px; font-weight: 700; font-family: var(--font-display); color: var(--ios-green); flex-shrink: 0; }
.mc-reward small { font-size: 12px; font-weight: 500; color: var(--label-secondary); }
.mc-bottom {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px 14px; border-top: 0.5px solid var(--separator);
}
.badge {
padding: 3px 9px; border-radius: 20px; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.3px;
}
.b-open { background: rgba(52,199,89,0.15); color: var(--ios-green); }
.b-active { background: rgba(10,132,255,0.15); color: #0A84FF; }
.b-completed { background: rgba(88,86,214,0.15); color: var(--ios-indigo); }
.b-cancelled { background: rgba(255,69,58,0.15); color: var(--ios-red); }
.mc-cta { font-size: 14px; font-weight: 600; color: #0A84FF; display: flex; align-items: center; gap: 4px; }
/* Active mission banner */
.active-banner {
margin: 12px 16px;
background: linear-gradient(135deg, rgba(10,132,255,0.18), rgba(94,92,230,0.18));
border: 1px solid rgba(10,132,255,0.35);
border-radius: 16px; padding: 16px; display: none;
}
.active-banner.show { display: block; }
.ab-label { font-size: 11px; font-weight: 700; color: #0A84FF; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.ab-title { font-size: 17px; font-weight: 700; margin-bottom: 4px; }
.ab-loc { font-size: 13px; color: var(--label-secondary); margin-bottom: 12px; }
.ab-actions { display: flex; gap: 8px; }
.ab-btn {
flex: 1; padding: 11px 8px; border-radius: 11px; border: none;
font-family: var(--font-text); font-size: 14px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
.ab-btn:active { opacity: 0.7; }
.btn-cam { background: var(--fill-secondary); color: var(--label-primary); }
.btn-done { background: var(--ios-green); color: #fff; }
.btn-cancel-sm { background: rgba(255,69,58,0.12); color: var(--ios-red); }
/* Empty state */
.empty-state {
padding: 72px 32px; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.empty-icon { font-size: 60px; }
.empty-title { font-size: 20px; font-weight: 700; }
.empty-sub { font-size: 15px; color: var(--label-secondary); line-height: 1.5; }
/* ══════════════════════════════════
INTÄKTER TAB — Earnings
══════════════════════════════════ */
.earnings-hero {
background: linear-gradient(160deg, #061628 0%, #010b1a 50%, #030e21 100%);
padding: 28px 24px 32px; text-align: center; position: relative; overflow: hidden;
}
.earnings-hero::before {
content: '';
position: absolute; top: -60%; left: -30%; right: -30%; bottom: -20%;
background: radial-gradient(circle at 50% 35%, rgba(10,132,255,0.28) 0%, transparent 65%);
pointer-events: none;
}
.eh-label {
font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.75);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;
}
.eh-amount {
font-family: var(--font-display); font-size: 60px; font-weight: 800;
color: #fff; letter-spacing: -2.5px; line-height: 1; position: relative; z-index: 1;
}
.eh-currency { font-size: 28px; font-weight: 500; color: rgba(255,255,255,0.75); }
.eh-sub { font-size: 13px; color: rgba(255,255,255,0.75); margin-top: 6px; position: relative; z-index: 1; }
.eh-actions { display: flex; gap: 10px; margin-top: 20px; position: relative; z-index: 1; }
.eh-btn {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;
background: rgba(255,255,255,0.1); border: none; border-radius: 14px;
padding: 13px 8px; cursor: pointer; transition: background 0.15s; font-family: var(--font-text);
}
.eh-btn:active { background: rgba(255,255,255,0.18); }
.eh-btn-icon { font-size: 22px; }
.eh-btn-label { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.75); }
.earnings-stats {
display: grid; grid-template-columns: repeat(3, 1fr);
background: var(--background-secondary); border-bottom: 0.5px solid var(--separator);
}
.e-stat { padding: 16px 8px; text-align: center; border-right: 0.5px solid var(--separator); }
.e-stat:last-child { border-right: none; }
.e-stat-val { font-size: 20px; font-weight: 700; font-family: var(--font-display); }
.e-stat-lbl { font-size: 11px; color: var(--label-secondary); margin-top: 2px; }
/* Payout CTA */
.payout-card {
margin: 16px; background: var(--background-secondary);
border-radius: 16px; overflow: hidden; border: 0.5px solid var(--separator);
}
.payout-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 0.5px solid var(--separator);
}
.payout-title { font-size: 16px; font-weight: 600; }
.payout-badge { padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 700; }
.pb-pending { background: rgba(255,149,0,0.12); color: var(--ios-orange); }
.payout-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 0.5px solid var(--separator);
}
.payout-row:last-child { border-bottom: none; }
.pr-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; flex-shrink: 0;
}
.pri-credit { background: rgba(52,199,89,0.15); }
.pri-pending { background: rgba(255,149,0,0.15); }
.pri-payout { background: rgba(10,132,255,0.12); }
.pr-body { flex: 1; min-width: 0; }
.pr-title { font-size: 15px; font-weight: 600; }
.pr-sub { font-size: 13px; color: var(--label-secondary); margin-top: 1px; }
.pr-amount { font-size: 16px; font-weight: 700; font-family: var(--font-display); }
.pr-amount.credit { color: var(--ios-green); }
.pr-amount.pending { color: var(--ios-orange); }
.pr-amount.payout { color: #0A84FF; }
/* ══════════════════════════════════
LEADERBOARD TAB
══════════════════════════════════ */
.lb-hero {
padding: 20px 16px 12px;
background: linear-gradient(135deg, rgba(10,132,255,0.1) 0%, rgba(94,92,230,0.1) 100%);
border-bottom: 0.5px solid var(--separator);
}
.lb-hero-title { font-size: 28px; font-weight: 800; letter-spacing: -1px; }
.lb-hero-sub { font-size: 14px; color: var(--label-secondary); margin-top: 4px; }
.lb-totals {
display: flex; gap: 16px; margin-top: 14px; flex-wrap: wrap;
}
.lb-total-chip {
display: flex; align-items: center; gap: 6px;
background: var(--fill-secondary); border-radius: 20px;
padding: 6px 12px; font-size: 13px; font-weight: 600;
}
.lb-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 0.5px solid var(--separator);
cursor: pointer; transition: background 0.1s;
}
.lb-row:active { background: var(--fill-quaternary); }
.lb-rank {
font-size: 18px; font-weight: 800; font-family: var(--font-display);
min-width: 28px; text-align: center; flex-shrink: 0;
}
.rank-1 { color: #FFD700; }
.rank-2 { color: #C0C0C0; }
.rank-3 { color: #CD7F32; }
.rank-n { color: var(--label-tertiary); }
.lb-avatar {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
.lb-body { flex: 1; min-width: 0; }
.lb-name { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; }
.lb-meta { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.lb-right { text-align: right; flex-shrink: 0; }
.lb-missions { font-size: 20px; font-weight: 800; font-family: var(--font-display); }
.lb-earnings { font-size: 12px; color: var(--label-secondary); margin-top: 1px; }
.city-row {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-bottom: 0.5px solid var(--separator);
}
.city-rank {
font-size: 20px; font-weight: 800; font-family: var(--font-display);
min-width: 30px; text-align: center; flex-shrink: 0;
}
.city-icon { font-size: 28px; flex-shrink: 0; }
.city-body { flex: 1; min-width: 0; }
.city-name { font-size: 16px; font-weight: 700; }
.city-meta { font-size: 13px; color: var(--label-secondary); margin-top: 1px; }
.city-bar-wrap { flex: 1; }
.city-bar-track { height: 5px; background: var(--fill-secondary); border-radius: 3px; overflow: hidden; }
.city-bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #0A84FF, #5E5CE6); }
.city-count { font-size: 18px; font-weight: 700; font-family: var(--font-display); text-align: right; flex-shrink: 0; }
/* ══════════════════════════════════
SHARED UTILITIES
══════════════════════════════════ */
.toast {
position: fixed; bottom: calc(90px + env(safe-area-inset-bottom, 0px));
left: 50%; transform: translateX(-50%) translateY(20px);
background: rgba(50,50,52,0.96); color: #fff;
padding: 10px 20px; border-radius: 20px; font-size: 14px; font-weight: 500;
opacity: 0; transition: all 0.3s; z-index: 1000; white-space: nowrap; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.loading-row { display: flex; justify-content: center; padding: 48px; }
.spinner {
width: 28px; height: 28px; border: 3px solid var(--fill-secondary);
border-top-color: #0A84FF; border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pull-hint { text-align: center; padding: 12px; font-size: 12px; color: var(--label-quaternary); }
/* Upload sheet */
.upload-sheet {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 600;
background: rgba(28,28,30,0.98); backdrop-filter: blur(40px);
border-top-left-radius: 20px; border-top-right-radius: 20px;
padding: 20px 20px calc(env(safe-area-inset-bottom,34px) + 20px);
transform: translateY(100%); transition: transform 0.35s cubic-bezier(0.32,0.72,0,1);
border-top: 0.5px solid var(--separator);
}
.upload-sheet.show { transform: translateY(0); }
.upload-handle { width: 36px; height: 4px; border-radius: 2px; background: var(--fill-primary); margin: 0 auto 20px; }
.upload-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.upload-sub { font-size: 14px; color: var(--label-secondary); margin-bottom: 20px; }
.upload-previews { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
.preview-thumb {
width: 80px; height: 80px; border-radius: 10px; object-fit: cover;
border: 0.5px solid var(--separator); background: var(--fill-secondary);
display: flex; align-items: center; justify-content: center; font-size: 28px;
}
.upload-actions { display: flex; gap: 10px; }
.upload-btn {
flex: 1; padding: 14px; border-radius: 12px; border: none;
font-family: var(--font-text); font-size: 15px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
.upload-btn:active { opacity: 0.7; }
</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; }
</style>
<script src="/quixzoom/i18n.js"></script>
<meta property="og:type" content="website">
<meta property="og:title" content="quiXzoom — Zoomer App">
<meta property="og:description" content="quiXzoom — Earn per Mission. No Platform Fee.">
<meta property="og:url" content="https://www.quixzoom.com/zoomer-app.html">
<meta property="og:image" content="https://www.quixzoom.com/images/app-screenshot.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="quiXzoom app on iPhone showing missions and QX Credit rewards">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://www.quixzoom.com/images/app-screenshot.jpg">
<meta name="twitter:title" content="quiXzoom — Zoomer App">
</head>
<body>
<!-- H1 for SEO -->
<h1 style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">quiXzoom Zoomer App — Find Missions & Earn Money</h1>
<!-- ══════════════════════════════ KARTA TAB ══════════════════════════════ -->
<div id="page-karta" class="tab-page active">
<nav class="nav-bar">
<span class="nav-title nav-logo">quiXzoom</span>
<button class="nav-btn-sm" onclick="refreshMap()"></button>
</nav>
<div class="map-filter-bar" id="city-filters">
<button class="filter-chip active" data-city="" onclick="setCityFilter('',this)">Alla städer</button>
<button class="filter-chip" data-city="Stockholm" onclick="setCityFilter('Stockholm',this)">Stockholm</button>
<button class="filter-chip" data-city="Göteborg" onclick="setCityFilter('Göteborg',this)">Göteborg</button>
<button class="filter-chip" data-city="Malmö" onclick="setCityFilter('Malmö',this)">Malmö</button>
<button class="filter-chip" data-city="Uppsala" onclick="setCityFilter('Uppsala',this)">Uppsala</button>
<button class="filter-chip" data-city="Västerås" onclick="setCityFilter('Västerås',this)">Västerås</button>
</div>
<div id="map-full"></div>
<div class="map-legend">
<div class="legend-row"><div class="legend-dot" style="background:#0A84FF"></div>Foto</div>
<div class="legend-row"><div class="legend-dot" style="background:#5E5CE6"></div>Video</div>
<div class="legend-row"><div class="legend-dot" style="background:#FF9500"></div>Drönare</div>
</div>
</div>
<!-- Mission bottom-sheet (karta) -->
<div class="mission-sheet" id="mission-sheet">
<div class="sheet-card">
<div class="sheet-header">
<div class="sheet-title" id="sheet-title"></div>
<button class="sheet-close" onclick="closeSheet()"></button>
</div>
<div class="sheet-meta" id="sheet-meta"></div>
<div class="sheet-desc" id="sheet-desc"></div>
<div class="sheet-actions">
<button class="sheet-btn btn-secondary" onclick="closeSheet()">Stäng</button>
<button class="sheet-btn btn-primary" id="sheet-accept-btn" onclick="acceptFromSheet()">Acceptera uppdrag</button>
</div>
</div>
</div>
<!-- ══════════════════════════════ MINA UPPDRAG TAB ══════════════════════════════ -->
<div id="page-mina" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Mina uppdrag</span>
</nav>
<div class="segment-wrap">
<div class="segment">
<button class="seg-btn active" onclick="setMiMissionsFilter('all',this)">Alla</button>
<button class="seg-btn" onclick="setMiMissionsFilter('active',this)">Aktiva</button>
<button class="seg-btn" onclick="setMiMissionsFilter('completed',this)">Slutförda</button>
</div>
</div>
<div id="active-banner" class="active-banner">
<div class="ab-label">🔥 Aktivt uppdrag</div>
<div class="ab-title" id="ab-title"></div>
<div class="ab-loc" id="ab-loc"></div>
<div class="ab-actions">
<button class="ab-btn btn-cam" onclick="openUploadSheet()">📷 Ladda upp media</button>
<button class="ab-btn btn-done" onclick="completeActiveMission()">✅ Slutför</button>
<button class="ab-btn btn-cancel-sm" onclick="cancelActiveMission()"></button>
</div>
</div>
<div class="section-hdr" id="mina-section-lbl">Öppna uppdrag</div>
<div id="mina-list" class="missions-list"></div>
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ INTÄKTER TAB ══════════════════════════════ -->
<div id="page-intakter" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Intäkter</span>
</nav>
<div class="earnings-hero">
<div class="eh-label">Tillgängligt att ta ut</div>
<div class="eh-amount"><span class="eh-currency">kr </span><span id="e-avail">4 820</span></div>
<div class="eh-sub" id="e-pending-lbl">kr 1 250 väntar på verifiering</div>
<div class="eh-actions">
<button class="eh-btn" onclick="requestPayout()">
<span class="eh-btn-icon">💳</span><span class="eh-btn-label">Ta ut</span>
</button>
<button class="eh-btn" onclick="showToast('🧾 Exporterar årssammanfattning...')">
<span class="eh-btn-icon">🧾</span><span class="eh-btn-label">Skatt</span>
</button>
</div>
</div>
<div class="earnings-stats">
<div class="e-stat">
<div class="e-stat-val" id="e-month" style="color:var(--ios-green)"></div>
<div class="e-stat-lbl">Denna månad</div>
</div>
<div class="e-stat">
<div class="e-stat-val" id="e-total-missions" style="color:#0A84FF"></div>
<div class="e-stat-lbl">Uppdrag totalt</div>
</div>
<div class="e-stat">
<div class="e-stat-val" id="e-avg" style="color:var(--ios-orange)"></div>
<div class="e-stat-lbl">Snitt/uppdrag</div>
</div>
</div>
<div class="section-hdr">Väntande utbetalningar</div>
<div class="payout-card">
<div class="payout-header">
<div class="payout-title">Inkommande utbetalning</div>
<div class="payout-badge pb-pending">Pågår</div>
</div>
<div class="payout-row">
<div class="pr-icon pri-credit">💰</div>
<div class="pr-body">
<div class="pr-title">Fastighetsfilm — Lidingö</div>
<div class="pr-sub">Verifieras · förfaller 5 jun</div>
</div>
<div class="pr-amount credit">+2 800 kr</div>
</div>
<div class="payout-row">
<div class="pr-icon pri-pending"></div>
<div class="pr-body">
<div class="pr-title">Hotell Grand Stockholm</div>
<div class="pr-sub">Granskas av kund · 23 dagar</div>
</div>
<div class="pr-amount pending">~7 200 kr</div>
</div>
</div>
<div class="section-hdr">Transaktionshistorik</div>
<div class="payout-card" style="margin-bottom:0">
<div id="tx-list"></div>
</div>
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ LEADERBOARD TAB ══════════════════════════════ -->
<div id="page-leaderboard" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Leaderboard</span>
</nav>
<div class="lb-hero">
<div class="lb-hero-title">Top Zoomers</div>
<div class="lb-hero-sub">Sverige · Augusti 2026 · Rankings uppdateras live</div>
<div class="lb-totals" id="lb-totals">
<div class="lb-total-chip">⚡ Laddar...</div>
</div>
</div>
<div class="segment-wrap">
<div class="segment">
<button class="seg-btn active" onclick="setLbView('zoomers',this)">Zoomers</button>
<button class="seg-btn" onclick="setLbView('cities',this)">Städer</button>
</div>
</div>
<div id="lb-zoomers-list"></div>
<div id="lb-cities-list" style="display:none"></div>
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ TAB BAR ══════════════════════════════ -->
<nav class="tab-bar">
<button class="tab-item active" id="tab-karta" onclick="switchTab('karta')">
<span class="tab-icon">🗺️</span>
<span class="tab-label">Karta</span>
</button>
<button class="tab-item" id="tab-mina" onclick="switchTab('mina')">
<span class="tab-badge-wrap">
<span class="tab-icon">📋</span>
<span class="tab-dot" id="mina-dot"></span>
</span>
<span class="tab-label">Mina</span>
</button>
<button class="tab-item" id="tab-intakter" onclick="switchTab('intakter')">
<span class="tab-icon">💰</span>
<span class="tab-label">Intäkter</span>
</button>
<button class="tab-item" id="tab-leaderboard" onclick="switchTab('leaderboard')">
<span class="tab-icon">🏆</span>
<span class="tab-label">Topp</span>
</button>
</nav>
<div class="toast" id="toast"></div>
<!-- Upload sheet -->
<div class="upload-sheet" id="upload-sheet">
<div class="upload-handle"></div>
<div class="upload-title">Ladda upp bevis</div>
<div class="upload-sub">Bifoga foton eller video som bevis på slutfört uppdrag</div>
<div class="upload-previews" id="upload-previews"></div>
<div class="upload-actions">
<button class="upload-btn" style="background:var(--fill-secondary);color:var(--label-primary)" onclick="triggerFileInput()">📷 Välj fil</button>
<button class="upload-btn btn-primary" onclick="submitMedia()">Skicka bevis </button>
</div>
</div>
<div id="upload-overlay" onclick="closeUploadSheet()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:595;"></div>
<input type="file" id="file-input" accept="image/*,video/*" capture="environment" multiple style="display:none">
<script>
const API = 'https://api.quixzoom.com/api/qz';
const DEMO_ZOOMER_ID = 'qz-zreg-001';
let allMapMissions = [];
let mapInstance = null;
let mapMarkers = [];
let cityFilter = '';
let selectedMapMission = null;
let activeMission = null;
let myMissions = [];
let miMissionsFilter = 'all';
let lbView = 'zoomers';
let lbData = null;
let uploadedMediaUrls = [];
// ── Toast ──────────────────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
// ── Tab switching ──────────────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab-page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + tab).classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'karta') initMap();
if (tab === 'mina') loadMinaMissions();
if (tab === 'intakter') loadEarnings();
if (tab === 'leaderboard') loadLeaderboard();
}
// ── Active mission (localStorage) ─────────────────────────────────────────
function setActiveMission(m) {
activeMission = m;
if (m) localStorage.setItem('qz_active', JSON.stringify(m));
else localStorage.removeItem('qz_active');
updateMinaDot();
}
function getActiveMission() {
if (activeMission) return activeMission;
try { return JSON.parse(localStorage.getItem('qz_active')); } catch { return null; }
}
function updateMinaDot() {
const dot = document.getElementById('mina-dot');
if (dot) dot.classList.toggle('show', !!getActiveMission());
}
// ══════════════════════════════════════════════════════════════════════════
// KARTA TAB
// ══════════════════════════════════════════════════════════════════════════
const TYPE_CFG = {
photo: { icon: '📷', color: '#0A84FF', bg: 'rgba(10,132,255,0.2)' },
video: { icon: '🎬', color: '#5E5CE6', bg: 'rgba(94,92,230,0.2)' },
drone: { icon: '🚁', color: '#FF9500', bg: 'rgba(255,149,0,0.2)' },
survey: { icon: '📋', color: '#34C759', bg: 'rgba(52,199,89,0.2)' },
audit: { icon: '🔍', color: '#FF2D55', bg: 'rgba(255,45,85,0.2)' },
};
function getCfg(t) { return TYPE_CFG[t] || { icon: '📍', color: '#8E8E93', bg: 'rgba(142,142,147,0.2)' }; }
async function fetchMapMissions(city = '') {
try {
const qs = city ? `?city=${encodeURIComponent(city)}&status=open` : '?status=open';
const r = await fetch(API + '/mission/list' + qs);
const d = await r.json();
allMapMissions = d.missions || DEMO_MISSIONS;
} catch {
allMapMissions = DEMO_MISSIONS.filter(m => !city || m.location?.city === city);
}
}
async function initMap() {
if (!mapInstance) {
mapInstance = L.map('map-full', { zoomControl: false }).setView([59.5, 16.5], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OSM' }).addTo(mapInstance);
}
await fetchMapMissions(cityFilter);
renderMapMarkers();
}
function renderMapMarkers() {
mapMarkers.forEach(m => m.remove());
mapMarkers = [];
if (!mapInstance) return;
allMapMissions.forEach(m => {
const lat = m.location?.lat, lon = m.location?.lon;
if (!lat || !lon) return;
const cfg = getCfg(m.mission_type);
const icon = L.divIcon({
html: `<div style="width:40px;height:40px;border-radius:50%;background:${cfg.bg};border:2.5px solid ${cfg.color};display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 2px 10px rgba(0,0,0,0.4);">${cfg.icon}</div>`,
className: '', iconSize: [40,40], iconAnchor: [20,20]
});
const marker = L.marker([lat, lon], { icon }).addTo(mapInstance);
marker.on('click', () => openMapSheet(m));
mapMarkers.push(marker);
});
if (cityFilter && allMapMissions.length > 0) {
const first = allMapMissions.find(m => m.location?.lat);
if (first) mapInstance.setView([first.location.lat, first.location.lon], 12);
}
}
function setCityFilter(city, btn) {
cityFilter = city;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
closeSheet();
if (mapInstance) fetchMapMissions(city).then(renderMapMarkers);
}
function refreshMap() {
initMap();
showToast('🔄 Uppdaterar karta...');
}
function openMapSheet(mission) {
selectedMapMission = mission;
const cfg = getCfg(mission.mission_type);
const deadline = mission.deadline ? new Date(mission.deadline).toLocaleDateString('sv-SE', { day:'numeric', month:'short' }) : '—';
document.getElementById('sheet-title').textContent = mission.title;
document.getElementById('sheet-desc').textContent = mission.description;
document.getElementById('sheet-meta').innerHTML = `
<span class="sheet-chip chip-reward">${mission.reward_sek?.toLocaleString('sv-SE')} kr</span>
<span class="sheet-chip chip-city">${mission.location?.city || '—'}</span>
<span class="sheet-chip chip-type">${cfg.icon} ${mission.mission_type}</span>
<span class="sheet-chip chip-dead">⏰ ${deadline}</span>`;
const m = getActiveMission();
const btn = document.getElementById('sheet-accept-btn');
if (m) { btn.textContent = 'Du har ett aktivt uppdrag'; btn.disabled = true; btn.style.opacity = '0.5'; }
else { btn.textContent = 'Acceptera uppdrag'; btn.disabled = false; btn.style.opacity = '1'; }
document.getElementById('mission-sheet').classList.add('show');
}
function closeSheet() {
document.getElementById('mission-sheet').classList.remove('show');
selectedMapMission = null;
}
async function acceptFromSheet() {
if (!selectedMapMission) return;
const m = selectedMapMission;
closeSheet();
try {
const r = await fetch(API + '/zoomer/accept-mission', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getDemoToken() },
body: JSON.stringify({ mission_id: m.id, zoomer_id: DEMO_ZOOMER_ID }),
});
const d = await r.json();
if (d.ok) {
setActiveMission(m);
showToast('✅ Uppdrag accepterat! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
allMapMissions = allMapMissions.filter(x => x.id !== m.id);
renderMapMarkers();
} else {
showToast('⚠️ ' + (d.error || 'Fel vid accepterande'));
}
} catch {
// Offline demo fallback
setActiveMission(m);
showToast('✅ Uppdrag accepterat (offline)! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
allMapMissions = allMapMissions.filter(x => x.id !== m.id);
renderMapMarkers();
}
}
// ══════════════════════════════════════════════════════════════════════════
// MINA UPPDRAG TAB
// ══════════════════════════════════════════════════════════════════════════
async function loadMinaMissions() {
renderActiveBanner();
const active = getActiveMission();
try {
const r = await fetch(API + '/mission/list?status=open', {
headers: { 'Authorization': 'Bearer ' + getDemoToken() }
});
const d = await r.json();
myMissions = d.missions || DEMO_MISSIONS;
} catch {
myMissions = DEMO_MISSIONS;
}
if (active) {
const alreadyInList = myMissions.find(m => m.id === active.id);
if (!alreadyInList) {
myMissions = [{ ...active, status: 'active' }, ...myMissions];
}
}
renderMyMissions();
}
function setMiMissionsFilter(f, btn) {
miMissionsFilter = f;
document.querySelectorAll('.segment .seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderMyMissions();
}
function renderMyMissions() {
const active = getActiveMission();
let list = [...myMissions];
if (active && !list.find(m => m.id === active.id)) {
list = [{ ...active, status: 'active' }, ...list];
}
const lbl = document.getElementById('mina-section-lbl');
if (miMissionsFilter === 'active') {
list = list.filter(m => m.status === 'active' || (active && m.id === active.id));
lbl.textContent = 'Aktiva uppdrag';
} else if (miMissionsFilter === 'completed') {
list = list.filter(m => m.status === 'completed');
lbl.textContent = 'Slutförda uppdrag';
} else {
lbl.textContent = 'Alla uppdrag';
}
const el = document.getElementById('mina-list');
if (!list.length) {
el.innerHTML = `<div class="empty-state"><div class="empty-icon">📋</div><div class="empty-title">Inga uppdrag</div><div class="empty-sub">Öppna Karta-fliken och acceptera ett uppdrag för att komma igång!</div></div>`;
return;
}
el.innerHTML = list.map(m => {
const cfg = getCfg(m.mission_type || 'photo');
const isActive = active && active.id === m.id;
const statusCls = isActive ? 'b-active' : m.status === 'completed' ? 'b-completed' : m.status === 'cancelled' ? 'b-cancelled' : 'b-open';
const statusLbl = isActive ? 'Aktivt' : m.status === 'completed' ? 'Slutfört' : m.status === 'cancelled' ? 'Avbrutet' : 'Öppet';
const deadline = m.deadline ? new Date(m.deadline).toLocaleDateString('sv-SE',{day:'numeric',month:'short'}) : '';
return `<div class="mission-card" onclick="openMissionDetail('${m.id}')" style="${isActive ? 'border-color:#0A84FF;' : ''}">
<div class="mc-top">
<div class="mc-icon" style="background:${cfg.bg}">${cfg.icon}</div>
<div class="mc-body">
<div class="mc-title">${m.title}</div>
<div class="mc-loc">📍 ${m.location?.address || m.location?.city || 'Okänd plats'}${deadline ? ' · ⏰ '+deadline : ''}</div>
</div>
<div class="mc-reward">${(m.reward_sek||0).toLocaleString('sv-SE')}<small> kr</small></div>
</div>
<div class="mc-bottom">
<span class="badge ${statusCls}">${statusLbl}</span>
<div class="mc-cta">${isActive ? '⚡ Pågår' : m.status === 'completed' ? '✓ Klart' : 'Detaljer'} </div>
</div>
</div>`;
}).join('');
}
function renderActiveBanner() {
const m = getActiveMission();
const banner = document.getElementById('active-banner');
if (!m) { banner.classList.remove('show'); return; }
banner.classList.add('show');
document.getElementById('ab-title').textContent = m.title;
document.getElementById('ab-loc').textContent = '📍 ' + (m.location?.address || m.location?.city || '—') + ' · Kr ' + (m.reward_sek||0).toLocaleString('sv-SE');
}
function openMissionDetail(id) {
const m = myMissions.find(x => x.id === id) || (getActiveMission()?.id === id ? getActiveMission() : null);
if (!m) return;
if (m.status === 'open') {
if (getActiveMission()) { showToast('⚠️ Du har redan ett aktivt uppdrag'); return; }
if (confirm(`Acceptera "${m.title}" för ${(m.reward_sek||0).toLocaleString('sv-SE')} kr?`)) {
acceptMission(m);
}
} else if (m.status === 'active' || (getActiveMission()?.id === m.id)) {
openUploadSheet();
}
}
async function acceptMission(m) {
try {
const r = await fetch(API + '/zoomer/accept-mission', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getDemoToken() },
body: JSON.stringify({ mission_id: m.id, zoomer_id: DEMO_ZOOMER_ID }),
});
const d = await r.json();
if (d.ok || true) {
setActiveMission(m);
showToast('✅ Uppdrag accepterat!');
loadMinaMissions();
}
} catch {
setActiveMission(m);
showToast('✅ Uppdrag accepterat!');
loadMinaMissions();
}
}
async function completeActiveMission() {
const m = getActiveMission();
if (!m) return;
const urls = uploadedMediaUrls.length > 0 ? uploadedMediaUrls : ['https://cdn.quixzoom.se/demo/media-proof-' + Date.now() + '.jpg'];
try {
const r = await fetch(API + '/mission/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getDemoToken() },
body: JSON.stringify({ mission_id: m.id, zoomer_id: DEMO_ZOOMER_ID, media_url: urls }),
});
const d = await r.json();
if (d.ok || d.pay_released) {
setActiveMission(null);
uploadedMediaUrls = [];
showToast('🎉 Uppdrag slutfört! Kr ' + (m.reward_sek||0).toLocaleString('sv-SE') + ' utbetalas');
loadMinaMissions();
return;
}
} catch { /* fallthrough */ }
setActiveMission(null);
uploadedMediaUrls = [];
showToast('🎉 Uppdrag slutfört! Betalning på väg.');
loadMinaMissions();
}
function cancelActiveMission() {
if (!confirm('Avbryt aktivt uppdrag?')) return;
setActiveMission(null);
showToast('Uppdrag avbrutet');
loadMinaMissions();
}
// ── Upload sheet ─────────────────────────────────────────────────────────
function openUploadSheet() {
document.getElementById('upload-sheet').classList.add('show');
document.getElementById('upload-overlay').style.display = 'block';
renderUploadPreviews();
}
function closeUploadSheet() {
document.getElementById('upload-sheet').classList.remove('show');
document.getElementById('upload-overlay').style.display = 'none';
}
function triggerFileInput() {
document.getElementById('file-input').click();
}
document.getElementById('file-input').addEventListener('change', function() {
const files = Array.from(this.files);
files.forEach(f => {
const url = URL.createObjectURL(f);
const fakeUploadUrl = 'https://cdn.quixzoom.se/uploads/' + Date.now() + '-' + f.name;
uploadedMediaUrls.push(fakeUploadUrl);
});
renderUploadPreviews();
if (files.length) showToast('📎 ' + files.length + ' fil(er) bifogade');
});
function renderUploadPreviews() {
const el = document.getElementById('upload-previews');
if (!uploadedMediaUrls.length) {
el.innerHTML = '<div class="preview-thumb" style="cursor:pointer" onclick="triggerFileInput()">+</div>';
return;
}
el.innerHTML = uploadedMediaUrls.map((u,i) =>
`<div class="preview-thumb" title="${u}">📷</div>`
).join('') + `<div class="preview-thumb" style="cursor:pointer;font-size:20px" onclick="triggerFileInput()">+</div>`;
}
function submitMedia() {
if (!uploadedMediaUrls.length) { showToast('⚠️ Välj minst en fil'); return; }
closeUploadSheet();
showToast('📤 Media uppladdad! Klicka Slutför när du är klar.');
}
// ══════════════════════════════════════════════════════════════════════════
// INTÄKTER TAB
// ══════════════════════════════════════════════════════════════════════════
async function loadEarnings() {
try {
const r = await fetch(API + '/earnings', { headers: { 'Authorization': 'Bearer ' + getDemoToken() } });
const d = await r.json();
if (d.ok) renderEarnings(d.earnings || d);
else renderEarnings(DEMO_EARNINGS);
} catch { renderEarnings(DEMO_EARNINGS); }
}
function renderEarnings(e) {
document.getElementById('e-avail').textContent = (e.available_sek || DEMO_EARNINGS.available_sek).toLocaleString('sv-SE');
document.getElementById('e-pending-lbl').textContent = 'kr ' + (e.pending_sek || DEMO_EARNINGS.pending_sek).toLocaleString('sv-SE') + ' väntar på verifiering';
document.getElementById('e-month').textContent = (e.this_month_sek || DEMO_EARNINGS.this_month_sek).toLocaleString('sv-SE') + ' kr';
document.getElementById('e-total-missions').textContent = e.missions_completed || DEMO_EARNINGS.missions_completed;
const avg = Math.round((e.total_earned_sek || DEMO_EARNINGS.total_earned_sek) / Math.max(e.missions_completed || DEMO_EARNINGS.missions_completed, 1));
document.getElementById('e-avg').textContent = avg.toLocaleString('sv-SE') + ' kr';
renderTransactions(DEMO_TX);
}
function renderTransactions(txs) {
document.getElementById('tx-list').innerHTML = txs.map(tx => `
<div class="payout-row">
<div class="pr-icon pri-${tx.type}">${tx.icon}</div>
<div class="pr-body">
<div class="pr-title">${tx.title}</div>
<div class="pr-sub">${tx.date}</div>
</div>
<div class="pr-amount ${tx.type}">${tx.type==='credit'?'+':tx.type==='payout'?'↑':'~'}${tx.amount.toLocaleString('sv-SE')} kr</div>
</div>`).join('');
}
// ══════════════════════════════════════════════════════════════════════════
// LEADERBOARD TAB
// ══════════════════════════════════════════════════════════════════════════
async function loadLeaderboard() {
try {
const r = await fetch(API + '/leaderboard', { headers: { 'Authorization': 'Bearer ' + getDemoToken() } });
lbData = await r.json();
} catch { lbData = DEMO_LEADERBOARD; }
renderLbTotals();
renderLbZoomers();
renderLbCities();
}
function setLbView(view, btn) {
lbView = view;
document.querySelectorAll('.segment .seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('lb-zoomers-list').style.display = view === 'zoomers' ? 'block' : 'none';
document.getElementById('lb-cities-list').style.display = view === 'cities' ? 'block' : 'none';
}
function renderLbTotals() {
if (!lbData?.platform_totals) return;
const t = lbData.platform_totals;
document.getElementById('lb-totals').innerHTML = `
<div class="lb-total-chip">🎯 ${t.missions_completed?.toLocaleString('sv-SE')} uppdrag</div>
<div class="lb-total-chip">💰 ${(t.total_paid_out_sek/1000).toFixed(0)}k kr utbetalt</div>
<div class="lb-total-chip">👤 ${t.registered_zoomers} zoomers</div>
<div class="lb-total-chip">🏙 ${t.cities_live} städer</div>`;
}
const ZOOMER_AVATARS = ['🦅','🦊','🐺','🦁','🐯','🦝','🦋','🦈'];
function renderLbZoomers() {
const list = (lbData?.top_zoomers || DEMO_LEADERBOARD.top_zoomers);
document.getElementById('lb-zoomers-list').innerHTML = list.map((z,i) => {
const rankCls = i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':'rank-n';
const rankTxt = i===0?'🥇':i===1?'🥈':i===2?'🥉':'#'+(i+1);
const bg = i===0?'rgba(255,215,0,0.15)':i===1?'rgba(192,192,192,0.12)':i===2?'rgba(205,127,50,0.12)':'var(--fill-tertiary)';
return `<div class="lb-row">
<div class="lb-rank ${rankCls}">${rankTxt}</div>
<div class="lb-avatar" style="background:${bg}">${ZOOMER_AVATARS[i%ZOOMER_AVATARS.length]}</div>
<div class="lb-body">
<div class="lb-name">${z.name}</div>
<div class="lb-meta">📍 ${z.city} · ⭐ ${z.rating} · ${z.skill_level}</div>
</div>
<div class="lb-right">
<div class="lb-missions" style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--label-primary)'}">${z.missions_completed}</div>
<div class="lb-earnings">${(z.earnings_sek/1000).toFixed(1)}k kr</div>
</div>
</div>`;
}).join('');
}
const CITY_ICONS = { Stockholm:'🏙', Göteborg:'⚓', Malmö:'🌉', Uppsala:'🏛', Västerås:'⚙️' };
function renderLbCities() {
const list = (lbData?.top_cities || DEMO_LEADERBOARD.top_cities);
const maxM = list[0]?.missions_completed || 1;
document.getElementById('lb-cities-list').innerHTML = list.map((c,i) => {
const pct = Math.round((c.missions_completed / maxM) * 100);
return `<div class="city-row">
<div class="city-rank" style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--label-tertiary)'}">${i===0?'🥇':i===1?'🥈':i===2?'🥉':'#'+(i+1)}</div>
<div class="city-icon">${CITY_ICONS[c.city]||'🏙'}</div>
<div class="city-body">
<div class="city-name">${c.city}</div>
<div style="margin-top:6px;max-width:160px;">
<div class="city-bar-track"><div class="city-bar-fill" style="width:${pct}%"></div></div>
</div>
<div class="city-meta">${c.active_zoomers} zoomers · ${c.avg_per_mission?.toLocaleString('sv-SE')} kr/snitt</div>
</div>
<div class="city-count">${c.missions_completed}</div>
</div>`;
}).join('');
}
// ── Helpers ────────────────────────────────────────────────────────────────
function getDemoToken() {
const payload = btoa(JSON.stringify({ sub: DEMO_ZOOMER_ID, email: 'erik@demo.se', iat: Date.now() }));
return 'eyJhbGciOiJIUzI1NiJ9.' + payload + '.demo-sig';
}
// ── Demo data ──────────────────────────────────────────────────────────────
const DEMO_MISSIONS = [
{ id:'qx-m-001', title:'Fastighetsfilm — Lidingö villa', description:'Professionell fastighetsfilm (4K) av villa.', location:{city:'Stockholm',address:'Lidingövägen 12',lat:59.3664,lon:18.1499}, reward_sek:2800, deadline:'2026-06-05T17:00:00Z', mission_type:'video', skill_required:'professional', status:'open' },
{ id:'qx-m-002', title:'Drönarbild — Kista Science City', description:'Aerial 4K-film av kontorspark. Tillstånd finns.', location:{city:'Stockholm',address:'Kistagången 20',lat:59.4044,lon:17.9491}, reward_sek:3200, deadline:'2026-06-08T16:00:00Z', mission_type:'drone', skill_required:'drone', status:'open' },
{ id:'qx-m-003', title:'Event-foto — Berns Salonger', description:'Eventfotografering för Tech Gala, ca 4h.', location:{city:'Stockholm',address:'Berzelii Park',lat:59.3336,lon:18.0741}, reward_sek:5500, deadline:'2026-06-12T23:00:00Z', mission_type:'photo', skill_required:'professional', status:'open' },
{ id:'qx-m-004', title:'Produktfoto — e-handel Södermalm', description:'80 produkter, studio tillhandahålls.', location:{city:'Stockholm',address:'Hornsgatan 64',lat:59.3157,lon:18.0532}, reward_sek:4200, deadline:'2026-06-03T15:00:00Z', mission_type:'photo', skill_required:'basic', status:'open' },
{ id:'qx-m-005', title:'Hotellfilm — Clarion Post Göteborg', description:'Marknadsföringsfilm av hotell.', location:{city:'Göteborg',address:'Drottningtorget 10',lat:57.7072,lon:11.9735}, reward_sek:6000, deadline:'2026-06-10T17:00:00Z', mission_type:'video', skill_required:'professional', status:'open' },
{ id:'qx-m-007', title:'Restaurangfoto — Vollmers Malmö', description:'Food & interior photography.', location:{city:'Malmö',address:'Tegelgårdsgatan 5',lat:55.5977,lon:13.0041}, reward_sek:4800, deadline:'2026-06-07T11:00:00Z', mission_type:'photo', skill_required:'professional', status:'open' },
{ id:'qx-m-008', title:'Drönarbild — Malmö Live', description:'Aerial film av konserthuset.', location:{city:'Malmö',address:'Lars Krantz Plats 1',lat:55.6060,lon:12.9991}, reward_sek:3600, deadline:'2026-06-14T16:00:00Z', mission_type:'drone', skill_required:'drone', status:'open' },
{ id:'qx-m-009', title:'Campus-film — Uppsala Universitet', description:'Rekryteringsfilm för campus.', location:{city:'Uppsala',address:'Universitetshuset',lat:59.8569,lon:17.6335}, reward_sek:5200, deadline:'2026-06-15T17:00:00Z', mission_type:'video', skill_required:'professional', status:'open' },
];
const DEMO_EARNINGS = { available_sek: 0, pending_sek: 0, this_month_sek: 0, missions_completed: 0, total_earned_sek: 0 };
const DEMO_TX = [
{ type:'credit', icon:'💰', title:'Drönarbild — Gamla Stan', date:'Idag · automatisk utbetalning', amount:2800 },
{ type:'credit', icon:'💰', title:'Butiksfilm — Nordstan', date:'Igår 16:40', amount:2400 },
{ type:'pending', icon:'⏳', title:'Hotellfilm Grand (granskas)', date:'2 dagar sedan · kunden granskar', amount:7200 },
{ type:'payout', icon:'↑', title:'Utbetalning via Stripe', date:'19 maj 2026', amount:9800 },
{ type:'credit', icon:'💰', title:'Event-foto Berns Salonger', date:'17 maj 2026', amount:5500 },
{ type:'credit', icon:'💰', title:'Campus-film Uppsala', date:'15 maj 2026', amount:5200 },
{ type:'credit', icon:'💰', title:'Restaurangfoto Vollmers', date:'12 maj 2026', amount:4800 },
{ type:'payout', icon:'↑', title:'Utbetalning via Stripe', date:'1 maj 2026', amount:14400 },
];
const DEMO_LEADERBOARD = {
platform_totals: { missions_completed: 0, total_paid_out_sek: 0, registered_zoomers: 0, cities_live: 0 },
top_zoomers: [],
top_cities: [],
};
// ── Init ───────────────────────────────────────────────────────────────────
updateMinaDot();
initMap();
</script>
</body>
</html>