Files
boc/quixzoom-landing-fixed/zoomer-app.html
T
Bernt 1a12fb870b dev-api: add developer portal, OpenAPI spec, and Flatenbadet case study
- 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
2026-07-14 15:27:33 +00:00

1383 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>quiXzoom — Zoomer App</title>
<meta name="description" content="The quiXzoom Zoomer App — find missions, capture photos, and earn money. Your gateway to flexible field work.">
<link rel="stylesheet" >
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
*, *::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: var(--radius-full);
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: 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: 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-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: 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;
}
.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: 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: var(--radius-full); 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: 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: 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: 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); }
.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: var(--radius-md); 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: var(--radius-lg); 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: var(--radius-md);
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: 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); }
.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: 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; }
.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: var(--radius-md); 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: 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); }
.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: 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: var(--radius-xl); 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: var(--radius-md);
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: var(--radius-xl);
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: var(--radius-full);
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: 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: 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); }
.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: var(--radius-full); 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: 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: 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: var(--radius-md); 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; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
/* QUIXZOOM STANDARD — Dark Theme */
:root {
--qz-bg: #0a0a0a;
--qz-surface: #111111;
--qz-surface-2: #1a1a1a;
--qz-border: rgba(255,255,255,0.08);
--qz-text: #e8e8e8;
--qz-text-dim: #a0a0a0;
--qz-text-muted: #6b6b6b;
--qz-blue: #007AFF;
--qz-blue-dark: #0056CC;
--qz-radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
background: var(--qz-bg);
color: var(--qz-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Navigation */
.qz-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 56px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--qz-border);
}
.qz-nav-logo { font-size: 18px; font-weight: 700; color: #fff; text-decoration: none; }
.qz-nav-links { display: flex; gap: 24px; list-style: none; }
.qz-nav-links a { font-size: 14px; color: var(--qz-text-dim); text-decoration: none; transition: color 0.2s; }
.qz-nav-links a:hover { color: #fff; }
/* Buttons */
.qz-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 12px 24px; border-radius: var(--qz-radius);
font-size: 15px; font-weight: 600; text-decoration: none;
transition: all 0.2s; cursor: pointer; border: none;
}
.qz-btn-primary {
background: var(--qz-blue); color: #fff;
}
.qz-btn-primary:hover {
background: var(--qz-blue-dark); transform: translateY(-1px);
}
.qz-btn-ghost {
background: transparent; color: var(--qz-text);
border: 1px solid var(--qz-border);
}
.qz-btn-ghost:hover {
border-color: var(--qz-blue); color: var(--qz-blue);
}
/* Cards */
.qz-card {
background: var(--qz-surface);
border: 1px solid var(--qz-border);
border-radius: var(--qz-radius);
padding: 24px;
transition: transform 0.2s, border-color 0.2s;
}
.qz-card:hover {
transform: translateY(-2px);
border-color: rgba(0,122,255,0.3);
}
/* Footer */
.qz-footer {
border-top: 1px solid var(--qz-border);
padding: 40px 24px;
text-align: center;
color: var(--qz-text-dim);
font-size: 14px;
}
/* Banner */
.qz-banner {
background: #001E50;
color: rgba(255,255,255,0.55);
text-align: center;
padding: 10px;
font-size: 12px;
}
.qz-banner a { color: rgba(255,255,255,0.8); text-decoration: none; }
@media (max-width: 768px) {
.qz-nav-links { display: none; }
}
</style>
<style id="wcag-contrast-enforcement">
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
/* Regel: Ingen text under 70% opacity. Aldrig. */
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* Fix common low-contrast patterns */
[style*="rgba(60,60,67,0.4)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(60,60,67,0.3)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(255,255,255,0.4)"] { color: rgba(255,255,255,0.72) !important; }
[style*="rgba(255,255,255,0.3)"] { color: rgba(255,255,255,0.72) !important; }
[style*="opacity: 0.4"] { opacity: 0.72 !important; }
[style*="opacity: 0.3"] { opacity: 0.72 !important; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
/* QUIXZOOM STANDARD — Dark Theme */
:root {
--qz-bg: #0a0a0a;
--qz-surface: #111111;
--qz-surface-2: #1a1a1a;
--qz-border: rgba(255,255,255,0.08);
--qz-text: #e8e8e8;
--qz-text-dim: #a0a0a0;
--qz-text-muted: #6b6b6b;
--qz-blue: #007AFF;
--qz-blue-dark: #0056CC;
--qz-radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
background: var(--qz-bg);
color: var(--qz-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Navigation */
.qz-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 56px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--qz-border);
}
.qz-nav-logo { font-size: 18px; font-weight: 700; color: #fff; text-decoration: none; }
.qz-nav-links { display: flex; gap: 24px; list-style: none; }
.qz-nav-links a { font-size: 14px; color: var(--qz-text-dim); text-decoration: none; transition: color 0.2s; }
.qz-nav-links a:hover { color: #fff; }
/* Buttons */
.qz-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 12px 24px; border-radius: var(--qz-radius);
font-size: 15px; font-weight: 600; text-decoration: none;
transition: all 0.2s; cursor: pointer; border: none;
}
.qz-btn-primary {
background: var(--qz-blue); color: #fff;
}
.qz-btn-primary:hover {
background: var(--qz-blue-dark); transform: translateY(-1px);
}
.qz-btn-ghost {
background: transparent; color: var(--qz-text);
border: 1px solid var(--qz-border);
}
.qz-btn-ghost:hover {
border-color: var(--qz-blue); color: var(--qz-blue);
}
/* Cards */
.qz-card {
background: var(--qz-surface);
border: 1px solid var(--qz-border);
border-radius: var(--qz-radius);
padding: 24px;
transition: transform 0.2s, border-color 0.2s;
}
.qz-card:hover {
transform: translateY(-2px);
border-color: rgba(0,122,255,0.3);
}
/* Footer */
.qz-footer {
border-top: 1px solid var(--qz-border);
padding: 40px 24px;
text-align: center;
color: var(--qz-text-dim);
font-size: 14px;
}
/* Banner */
.qz-banner {
background: #001E50;
color: rgba(255,255,255,0.55);
text-align: center;
padding: 10px;
font-size: 12px;
}
.qz-banner a { color: rgba(255,255,255,0.8); text-decoration: none; }
@media (max-width: 768px) {
.qz-nav-links { display: none; }
}
</style>
<script src="i18n.js"></script>
<meta property="og:type" content="website">
<meta property="og:title" content="quiXzoom — 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">
<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;">Zoomer App - quiXzoom</h1>
<!-- KARTA TAB -->
<div id="page-karta" class="tab-page active">
<nav class="qz-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="qz-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()"><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>
<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="qz-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="qz-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"><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">
<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="qz-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"><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-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()"><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>
<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: '<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)' }; }
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: 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);
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('<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) {
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('<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('<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('<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();
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"><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 => {
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 ? '<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('');
}
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('<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);
}
} 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('<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('<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();
}
}
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}"><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('<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.');
}
//
// 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:'<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-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>