feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+117 -117
View File
@@ -26,11 +26,11 @@
overflow-x: hidden;
}
/* ── Tab system ── */
/* Tab system */
.tab-page { display: none; }
.tab-page.active { display: block; }
/* ── Nav Bar ── */
/* Nav Bar */
.nav-bar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; justify-content: space-between;
@@ -59,7 +59,7 @@
font-size: 20px; padding: 4px;
}
/* ── Tab Bar ── */
/* Tab Bar */
.tab-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 300;
display: flex;
@@ -82,35 +82,35 @@
.tab-badge-wrap { position: relative; display: inline-block; }
.tab-dot {
position: absolute; top: -2px; right: -4px;
width: 8px; height: 8px; border-radius: 50%;
width: 8px; height: 8px; border-radius: var(--radius-full);
background: var(--ios-red); border: 1.5px solid var(--background-primary);
display: none;
}
.tab-dot.show { display: block; }
/* ── Segment ── */
/* Segment */
.segment-wrap { padding: 8px 16px; }
.segment { display: flex; background: var(--fill-tertiary); border-radius: 9px; padding: 2px; }
.segment { display: flex; background: var(--fill-tertiary); border-radius: var(--radius-md); 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;
color: var(--label-secondary); cursor: pointer; border-radius: var(--radius-sm); 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 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;
@@ -121,7 +121,7 @@
}
.map-filter-bar::-webkit-scrollbar { display: none; }
.filter-chip {
flex-shrink: 0; padding: 6px 14px; border-radius: 20px; border: 1.5px solid var(--separator);
flex-shrink: 0; padding: 6px 14px; border-radius: var(--radius-xl); 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;
}
@@ -130,12 +130,12 @@
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-radius: var(--radius-md); 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; }
.legend-dot { width: 10px; height: 10px; border-radius: var(--radius-full); flex-shrink: 0; }
/* Mission bottom-sheet */
.mission-sheet {
@@ -148,15 +148,15 @@
.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-radius: var(--radius-lg); 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-close { background: var(--fill-secondary); border: none; border-radius: var(--radius-full); 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; }
.sheet-chip { padding: 4px 10px; border-radius: var(--radius-xl); 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); }
@@ -164,7 +164,7 @@
.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;
flex: 1; padding: 13px; border-radius: var(--radius-md); border: none;
font-family: var(--font-text); font-size: 15px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
@@ -172,12 +172,12 @@
.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;
background: var(--background-secondary); border-radius: var(--radius-lg); overflow: hidden;
border: 0.5px solid var(--separator);
cursor: pointer; transition: transform 0.12s, opacity 0.12s;
}
@@ -186,7 +186,7 @@
display: flex; align-items: flex-start; gap: 12px; padding: 14px 14px 10px;
}
.mc-icon {
width: 46px; height: 46px; border-radius: 12px;
width: 46px; height: 46px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
@@ -203,7 +203,7 @@
padding: 10px 14px 14px; border-top: 0.5px solid var(--separator);
}
.badge {
padding: 3px 9px; border-radius: 20px; font-size: 11px; font-weight: 700;
padding: 3px 9px; border-radius: var(--radius-xl); 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); }
@@ -217,7 +217,7 @@
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;
border-radius: var(--radius-lg); 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; }
@@ -225,7 +225,7 @@
.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;
flex: 1; padding: 11px 8px; border-radius: var(--radius-md); border: none;
font-family: var(--font-text); font-size: 14px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
@@ -243,9 +243,9 @@
.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;
@@ -269,7 +269,7 @@
.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;
background: rgba(255,255,255,0.1); border: none; border-radius: var(--radius-lg);
padding: 13px 8px; cursor: pointer; transition: background 0.15s; font-family: var(--font-text);
}
.eh-btn:active { background: rgba(255,255,255,0.18); }
@@ -288,14 +288,14 @@
/* Payout CTA */
.payout-card {
margin: 16px; background: var(--background-secondary);
border-radius: 16px; overflow: hidden; border: 0.5px solid var(--separator);
border-radius: var(--radius-lg); 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; }
.payout-badge { padding: 4px 10px; border-radius: var(--radius-xl); font-size: 12px; font-weight: 700; }
.pb-pending { background: rgba(255,149,0,0.12); color: var(--ios-orange); }
.payout-row {
@@ -304,7 +304,7 @@
}
.payout-row:last-child { border-bottom: none; }
.pr-icon {
width: 40px; height: 40px; border-radius: 10px;
width: 40px; height: 40px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 20px; flex-shrink: 0;
}
@@ -319,9 +319,9 @@
.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%);
@@ -334,7 +334,7 @@
}
.lb-total-chip {
display: flex; align-items: center; gap: 6px;
background: var(--fill-secondary); border-radius: 20px;
background: var(--fill-secondary); border-radius: var(--radius-xl);
padding: 6px 12px; font-size: 13px; font-weight: 600;
}
@@ -353,7 +353,7 @@
.rank-3 { color: #CD7F32; }
.rank-n { color: var(--label-tertiary); }
.lb-avatar {
width: 44px; height: 44px; border-radius: 50%;
width: 44px; height: 44px; border-radius: var(--radius-full);
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
@@ -377,18 +377,18 @@
.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-bar-track { height: 5px; background: var(--fill-secondary); border-radius: var(--radius-sm); overflow: hidden; }
.city-bar-fill { height: 100%; border-radius: var(--radius-sm); 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;
padding: 10px 20px; border-radius: var(--radius-xl); 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); }
@@ -396,7 +396,7 @@
.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;
border-top-color: #0A84FF; border-radius: var(--radius-full); animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@@ -412,18 +412,18 @@
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-handle { width: 36px; height: 4px; border-radius: var(--radius-sm); 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;
width: 80px; height: 80px; border-radius: var(--radius-md); 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;
flex: 1; padding: 14px; border-radius: var(--radius-md); border: none;
font-family: var(--font-text); font-size: 15px; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
@@ -472,7 +472,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<body>
<h1 style="position:absolute;left:-9999px;">Zoomer App - quiXzoom</h1>
<!-- ══════════════════════════════ KARTA TAB ══════════════════════════════ -->
<!-- KARTA TAB -->
<div id="page-karta" class="tab-page active">
<nav class="nav-bar">
<span class="nav-title nav-logo">quiXzoom</span>
@@ -499,7 +499,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="sheet-card">
<div class="sheet-header">
<div class="sheet-title" id="sheet-title"></div>
<button class="sheet-close" onclick="closeSheet()"></button>
<button class="sheet-close" onclick="closeSheet()"></button>
</div>
<div class="sheet-meta" id="sheet-meta"></div>
<div class="sheet-desc" id="sheet-desc"></div>
@@ -510,7 +510,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
</div>
<!-- ══════════════════════════════ MINA UPPDRAG TAB ══════════════════════════════ -->
<!-- MINA UPPDRAG TAB -->
<div id="page-mina" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Mina uppdrag</span>
@@ -523,13 +523,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
</div>
<div id="active-banner" class="active-banner">
<div class="ab-label">🔥 Aktivt uppdrag</div>
<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>
<button class="ab-btn btn-cam" onclick="openUploadSheet()"><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> Ladda upp media</button>
<button class="ab-btn btn-done" onclick="completeActiveMission()"><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> 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>
@@ -537,7 +537,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ INTÄKTER TAB ══════════════════════════════ -->
<!-- INTÄKTER TAB -->
<div id="page-intakter" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Intäkter</span>
@@ -548,11 +548,11 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<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>
<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 class="eh-btn" onclick="showToast(' Exporterar årssammanfattning...')">
<span class="eh-btn-icon"></span><span class="eh-btn-label">Skatt</span>
</button>
</div>
</div>
@@ -578,7 +578,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<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-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>
@@ -602,7 +602,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ LEADERBOARD TAB ══════════════════════════════ -->
<!-- LEADERBOARD TAB -->
<div id="page-leaderboard" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Leaderboard</span>
@@ -611,7 +611,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<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 class="lb-total-chip"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg> Laddar...</div>
</div>
</div>
<div class="segment-wrap">
@@ -625,25 +625,25 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div style="height:20px"></div>
</div>
<!-- ══════════════════════════════ TAB BAR ══════════════════════════════ -->
<!-- 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-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-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></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-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-icon"></span>
<span class="tab-label">Topp</span>
</button>
</nav>
@@ -657,7 +657,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<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" style="background:var(--fill-secondary);color:var(--label-primary)" onclick="triggerFileInput()"><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> Välj fil</button>
<button class="upload-btn btn-primary" onclick="submitMedia()">Skicka bevis </button>
</div>
</div>
@@ -680,14 +680,14 @@ let lbView = 'zoomers';
let lbData = null;
let uploadedMediaUrls = [];
// ── Toast ──────────────────────────────────────────────────────────────────
// Toast
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
// ── Tab switching ──────────────────────────────────────────────────────────
// 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'));
@@ -699,7 +699,7 @@ function switchTab(tab) {
if (tab === 'leaderboard') loadLeaderboard();
}
// ── Active mission (localStorage) ─────────────────────────────────────────
// Active mission (localStorage)
function setActiveMission(m) {
activeMission = m;
if (m) localStorage.setItem('qz_active', JSON.stringify(m));
@@ -715,18 +715,18 @@ function updateMinaDot() {
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)' },
photo: { icon: '<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>', color: '#0A84FF', bg: 'rgba(10,132,255,0.2)' },
video: { icon: '', color: '#5E5CE6', bg: 'rgba(94,92,230,0.2)' },
drone: { icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2V6M4 4H12" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M2 8H6L8 6L10 8H14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 8V12H10V8" stroke="#666" stroke-width="1.5"/></svg>', color: '#FF9500', bg: 'rgba(255,149,0,0.2)' },
survey: { icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg>', color: '#34C759', bg: 'rgba(52,199,89,0.2)' },
audit: { icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5" stroke="#666" stroke-width="2"/><path d="M11 11L14 14" stroke="#666" stroke-width="2" stroke-linecap="round"/></svg>', 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)' }; }
function getCfg(t) { return TYPE_CFG[t] || { icon: '', color: '#8E8E93', bg: 'rgba(142,142,147,0.2)' }; }
async function fetchMapMissions(city = '') {
try {
@@ -757,7 +757,7 @@ function renderMapMarkers() {
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>`,
html: `<div style="width:40px;height:40px;border-radius: var(--radius-full);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);
@@ -780,7 +780,7 @@ function setCityFilter(city, btn) {
function refreshMap() {
initMap();
showToast('🔄 Uppdaterar karta...');
showToast('<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 8C2 11.3 4.7 14 8 14C10.5 14 12.7 12.5 13.7 10.3" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M14 8C14 4.7 11.3 2 8 2C5.5 2 3.3 3.5 2.3 5.7" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M13.5 7.5L14 10L11.5 9.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.5 8.5L2 6L4.5 6.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Uppdaterar karta...');
}
function openMapSheet(mission) {
@@ -818,24 +818,24 @@ async function acceptFromSheet() {
const d = await r.json();
if (d.ok) {
setActiveMission(m);
showToast(' Uppdrag accepterat! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
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 accepterat! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
allMapMissions = allMapMissions.filter(x => x.id !== m.id);
renderMapMarkers();
} else {
showToast('⚠️ ' + (d.error || 'Fel vid accepterande'));
showToast('<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> ' + (d.error || 'Fel vid accepterande'));
}
} catch {
// Offline demo fallback
setActiveMission(m);
showToast(' Uppdrag accepterat (offline)! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
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 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();
@@ -885,7 +885,7 @@ function renderMyMissions() {
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>`;
el.innerHTML = `<div class="empty-state"><div class="empty-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></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 => {
@@ -899,13 +899,13 @@ function renderMyMissions() {
<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 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 class="mc-cta">${isActive ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg> Pågår' : m.status === 'completed' ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Klart' : 'Detaljer'} </div>
</div>
</div>`;
}).join('');
@@ -917,14 +917,14 @@ function renderActiveBanner() {
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');
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 (getActiveMission()) { showToast('<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> Du har redan ett aktivt uppdrag'); return; }
if (confirm(`Acceptera "${m.title}" för ${(m.reward_sek||0).toLocaleString('sv-SE')} kr?`)) {
acceptMission(m);
}
@@ -943,12 +943,12 @@ async function acceptMission(m) {
const d = await r.json();
if (d.ok || true) {
setActiveMission(m);
showToast(' Uppdrag accepterat!');
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 accepterat!');
loadMinaMissions();
}
} catch {
setActiveMission(m);
showToast(' Uppdrag accepterat!');
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 accepterat!');
loadMinaMissions();
}
}
@@ -967,14 +967,14 @@ async function completeActiveMission() {
if (d.ok || d.pay_released) {
setActiveMission(null);
uploadedMediaUrls = [];
showToast('🎉 Uppdrag slutfört! Kr ' + (m.reward_sek||0).toLocaleString('sv-SE') + ' utbetalas');
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.');
showToast(' Uppdrag slutfört! Betalning på väg.');
loadMinaMissions();
}
@@ -985,7 +985,7 @@ function cancelActiveMission() {
loadMinaMissions();
}
// ── Upload sheet ─────────────────────────────────────────────────────────
// Upload sheet
function openUploadSheet() {
document.getElementById('upload-sheet').classList.add('show');
document.getElementById('upload-overlay').style.display = 'block';
@@ -1006,7 +1006,7 @@ document.getElementById('file-input').addEventListener('change', function() {
uploadedMediaUrls.push(fakeUploadUrl);
});
renderUploadPreviews();
if (files.length) showToast('📎 ' + files.length + ' fil(er) bifogade');
if (files.length) showToast(' ' + files.length + ' fil(er) bifogade');
});
function renderUploadPreviews() {
const el = document.getElementById('upload-previews');
@@ -1015,18 +1015,18 @@ function renderUploadPreviews() {
return;
}
el.innerHTML = uploadedMediaUrls.map((u,i) =>
`<div class="preview-thumb" title="${u}">📷</div>`
`<div class="preview-thumb" title="${u}"><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></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; }
if (!uploadedMediaUrls.length) { showToast('<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> Välj minst en fil'); return; }
closeUploadSheet();
showToast('📤 Media uppladdad! Klicka Slutför när du är klar.');
showToast(' Media uppladdad! Klicka Slutför när du är klar.');
}
// ══════════════════════════════════════════════════════════════════════════
//
// INTÄKTER TAB
// ══════════════════════════════════════════════════════════════════════════
//
async function loadEarnings() {
try {
@@ -1059,9 +1059,9 @@ function renderTransactions(txs) {
</div>`).join('');
}
// ══════════════════════════════════════════════════════════════════════════
//
// LEADERBOARD TAB
// ══════════════════════════════════════════════════════════════════════════
//
async function loadLeaderboard() {
try {
@@ -1085,25 +1085,25 @@ 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>`;
<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 = ['🦅','🦊','🐺','🦁','🐯','🦝','🦋','🦈'];
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 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 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>
@@ -1113,15 +1113,15 @@ function renderLbZoomers() {
}).join('');
}
const CITY_ICONS = { Stockholm:'🏙', Göteborg:'', Malmö:'🌉', Uppsala:'🏛', Västerås:'⚙️' };
const CITY_ICONS = { Stockholm:'', Göteborg:'', Malmö:'', Uppsala:'', Västerås:'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="3" stroke="#666" stroke-width="1.5"/><path d="M8 2V4M8 12V14M2 8H4M12 8H14M4.3 4.3L5.7 5.7M10.3 10.3L11.7 11.7M4.3 11.7L5.7 10.3M10.3 5.7L11.7 4.3" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg>' };
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-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;">
@@ -1134,13 +1134,13 @@ function renderLbCities() {
}).join('');
}
// ── Helpers ────────────────────────────────────────────────────────────────
// 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 ──────────────────────────────────────────────────────────────
// 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' },
@@ -1155,13 +1155,13 @@ const DEMO_MISSIONS = [
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:'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:'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 },
];
@@ -1171,7 +1171,7 @@ const DEMO_LEADERBOARD = {
top_cities: [],
};
// ── Init ───────────────────────────────────────────────────────────────────
// Init
updateMinaDot();
initMap();
</script>