Files
boc/quixzoom-landing-fixed/zoomer-earnings.html
T
Bernt 6989a98d75 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
2026-07-07 07:11:50 +00:00

496 lines
22 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="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<link rel="canonical" href="https://www.quixzoom.com/zoomer-earnings.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">
<title>Earnings — quiXzoom</title>
<meta name="description" content="Learn how much you can earn as a quiXzoom Zoomer. Transparent payout structure, instant payments, and bonus opportunities.">
<link rel="stylesheet" >
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-text);
background: var(--grouped-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;
}
/* Nav */
.nav-bar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
height: 44px; padding: 0 16px;
background: var(--navbar-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--separator);
}
.nav-title { font-size: 17px; font-weight: 600; letter-spacing: -0.41px; }
/* Hero earnings card */
.earnings-hero {
background: linear-gradient(160deg, #1a3a5c 0%, #0d2137 50%, #0a1929 100%);
padding: 28px 24px 32px;
text-align: center;
position: relative; overflow: hidden;
}
.earnings-hero::before {
content: '';
position: absolute; top: -40%; left: -20%; right: -20%; bottom: -20%;
background: radial-gradient(circle at 50% 40%, rgba(0,122,255,0.25) 0%, transparent 70%);
pointer-events: none;
}
.earnings-label {
font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.75);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
}
.earnings-amount {
font-family: var(--font-display);
font-size: 52px; font-weight: 800;
color: #fff; letter-spacing: -2px;
line-height: 1;
}
.earnings-currency { font-size: 24px; font-weight: 500; letter-spacing: -0.5px; color: rgba(60,60,67,0.7); }
.earnings-sub {
font-size: 14px; color: rgba(60,60,67,0.5);
margin-top: 8px;
}
/* Stats row */
.stats-row {
display: grid; grid-template-columns: 1fr 1fr 1fr;
background: var(--background-secondary);
border-bottom: 0.5px solid var(--separator);
}
.stat-cell {
padding: 16px 8px; text-align: center;
border-right: 0.5px solid var(--separator);
}
.stat-cell:last-child { border-right: none; }
.stat-val { font-size: 22px; font-weight: 700; font-family: var(--font-display); }
.stat-label { font-size: 11px; color: var(--label-secondary); margin-top: 2px; }
.green { color: var(--ios-green); }
.blue { color: var(--ios-blue); }
.orange { color: var(--ios-orange); }
/* Section */
.section { margin: 20px 16px 0; }
.section-header {
font-size: 20px; font-weight: 700; letter-spacing: -0.5px;
margin-bottom: 10px;
}
.section-card {
background: var(--background-secondary);
border-radius: var(--radius-lg); overflow: hidden;
border: 0.5px solid var(--separator);
}
/* Payout row */
.payout-row {
display: flex; align-items: center; gap: 12px;
padding: 13px 16px;
border-bottom: 0.5px solid var(--separator);
}
.payout-row:last-child { border-bottom: none; }
.payout-icon {
width: 38px; height: 38px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
}
.payout-body { flex: 1; min-width: 0; }
.payout-title { font-size: 15px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.payout-date { font-size: 12px; color: var(--label-secondary); margin-top: 2px; }
.payout-amount { font-size: 17px; font-weight: 700; font-family: var(--font-display); }
/* Connect card */
.connect-card {
background: var(--background-secondary);
border-radius: var(--radius-lg);
border: 0.5px solid var(--separator);
overflow: hidden;
}
.connect-top {
padding: 16px;
display: flex; gap: 14px; align-items: center;
}
.connect-icon {
width: 48px; height: 48px; border-radius: var(--radius-md);
background: rgba(0,122,255,0.15);
display: flex; align-items: center; justify-content: center;
font-size: 24px; flex-shrink: 0;
}
.connect-info { flex: 1; }
.connect-title { font-size: 17px; font-weight: 600; margin-bottom: 3px; }
.connect-desc { font-size: 13px; color: var(--label-secondary); }
.connect-btn {
display: block; width: 100%;
background: var(--ios-blue); color: #fff;
border: none; border-radius: 0; padding: 15px;
font-family: var(--font-text); font-size: 16px; font-weight: 700;
cursor: pointer; transition: opacity 0.15s;
letter-spacing: -0.2px;
border-top: 0.5px solid var(--separator);
}
.connect-btn:active { opacity: 0.8; }
.connect-status {
padding: 10px 16px;
font-size: 13px; color: var(--label-secondary);
border-top: 0.5px solid var(--separator);
display: flex; align-items: center; gap: 6px;
}
.status-dot {
width: 8px; height: 8px; border-radius: var(--radius-full);
}
.status-dot.active { background: var(--ios-green); }
.status-dot.pending { background: var(--ios-orange); }
.status-dot.inactive { background: var(--ios-gray3); }
/* Payout schedule */
.schedule-row {
display: flex; align-items: center; justify-content: space-between;
padding: 13px 16px;
border-bottom: 0.5px solid var(--separator);
}
.schedule-row:last-child { border-bottom: none; }
.schedule-label { font-size: 15px; }
.schedule-value { font-size: 15px; font-weight: 600; color: var(--label-secondary); }
/* Loader */
.loader { display:flex; justify-content:center; padding:40px; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width:28px; height:28px; border-radius: var(--radius-full); border:3px solid var(--fill-secondary); border-top-color:var(--ios-blue); animation:spin 0.8s linear infinite; }
/* Tab Bar */
.tab-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 300;
height: calc(49px + env(safe-area-inset-bottom, 34px));
background: var(--tabbar-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-top: 0.5px solid var(--separator);
display: flex; align-items: flex-start; padding-top: 8px;
}
.tab-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
gap: 3px; cursor: pointer; text-decoration: none; padding: 0 4px;
}
.tab-icon { font-size: 24px; line-height: 1; }
.tab-label { font-size: 10px; font-weight: 500; color: var(--label-tertiary); }
.tab-item.active .tab-label { color: var(--ios-blue); }
/* Toast */
.toast { position:fixed; bottom:calc(env(safe-area-inset-bottom,34px)+100px); left:50%; transform:translateX(-50%) translateY(20px); background:var(--background-secondary); border:0.5px solid var(--separator); border-radius: var(--radius-xl); padding:10px 18px; font-size:14px; font-weight:500; box-shadow:0 8px 32px rgba(0,0,0,0.3); opacity:0; transition:all 0.3s; z-index:9999; white-space:nowrap; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
@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; }
}
</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; }
}
</style>
<script src="i18n.js"></script>
<meta property="og:type" content="website">
<meta property="og:title" content="Earnings — quiXzoom">
<meta property="og:description" content="quiXzoom — Earn per Mission. No Platform Fee.">
<meta property="og:url" content="https://www.quixzoom.com/zoomer-earnings.html">
<meta property="og:image" content="https://www.quixzoom.com/images/zoomer-earnings.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="quiXzoom Zoomer earnings and QX Credits payout dashboard">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://www.quixzoom.com/images/zoomer-earnings.jpg">
<meta name="twitter:title" content="Earnings — quiXzoom">
</head>
<body>
<h1 style="position:absolute;left:-9999px;">Zoomer Earnings - quiXzoom</h1>
<!-- Nav -->
<div class="nav-bar">
<span class="nav-title">Earnings</span>
</div>
<!-- Hero -->
<div class="earnings-hero">
<div class="earnings-label">Total earned</div>
<div class="earnings-amount" id="total-amount"><span class="earnings-currency">SEK </span></div>
<div class="earnings-sub" id="earnings-sub">Loading data...</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-cell">
<div class="stat-val green" id="stat-uppdrag"></div>
<div class="stat-label">Missions</div>
</div>
<div class="stat-cell">
<div class="stat-val blue" id="stat-snittersattning"></div>
<div class="stat-label">Avg/mission</div>
</div>
<div class="stat-cell">
<div class="stat-val orange" id="stat-pending"></div>
<div class="stat-label">Pending</div>
</div>
</div>
<!-- Payouts -->
<div class="section">
<div class="section-header">Recent payouts</div>
<div class="section-card" id="payouts-card">
<div class="loader"><div class="spinner"></div></div>
</div>
</div>
<!-- Stripe Connect -->
<div class="section" style="margin-top:20px">
<div class="section-header">Payout account</div>
<div class="connect-card">
<div class="connect-top">
<div class="connect-icon"></div>
<div class="connect-info">
<div class="connect-title">Stripe Connect</div>
<div class="connect-desc">Verify your account to receive payouts directly to your bank</div>
</div>
</div>
<div class="connect-status" id="connect-status">
<div class="status-dot pending"></div>
Loading account status...
</div>
<button class="connect-btn" id="connect-btn" onclick="openConnectOnboarding()">
Configure payout account →
</button>
</div>
</div>
<!-- Payout schedule -->
<div class="section" style="margin-top:20px;margin-bottom:20px">
<div class="section-header">Payout schedule</div>
<div class="section-card">
<div class="schedule-row">
<span class="schedule-label"> Frekvens</span>
<span class="schedule-value">Every Monday</span>
</div>
<div class="schedule-row">
<span class="schedule-label">⏱ Processing time</span>
<span class="schedule-value">13 bankdagar</span>
</div>
<div class="schedule-row">
<span class="schedule-label"> Method</span>
<span class="schedule-value">Stripe Connect</span>
</div>
<div class="schedule-row">
<span class="schedule-label"> Currency</span>
<span class="schedule-value">SEK</span>
</div>
<div class="schedule-row">
<span class="schedule-label"><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> Min. payout</span>
<span class="schedule-value">500 kr</span>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<!-- Tab Bar -->
<nav class="tab-bar">
<a class="tab-item" href="zoomer-app.html">
<span class="tab-icon"></span>
<span class="tab-label">Missions</span>
</a>
<a class="tab-item active" href="zoomer-earnings.html">
<span class="tab-icon"></span>
<span class="tab-label">Earnings</span>
</a>
<a class="tab-item" href="zoomer-profile.html">
<span class="tab-icon"></span>
<span class="tab-label">Profile</span>
</a>
</nav>
<script>
const API = 'https://api.quixzoom.com/api/qz/missions';
function getZoomerId() {
let id = localStorage.getItem('qz_zoomer_id');
if (!id) { id = 'zoomer-' + Math.random().toString(36).slice(2,10); localStorage.setItem('qz_zoomer_id', id); }
return id;
}
function toast(msg, dur=3000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), dur);
}
function formatSEK(n) { return new Intl.NumberFormat('sv-SE').format(n); }
function formatDate(s) {
return new Date(s).toLocaleDateString('sv-SE', { day:'numeric', month:'short', year:'numeric' });
}
function missionTypeEmoji(type) {
const m = { facade_scan:'<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 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg>', roof_scan:'', road_inspection:'', interior_scan:'', vegetation_survey:'', inspection:'<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>' };
return m[type] || '';
}
function missionTypeBg(type) {
const m = { facade_scan:'rgba(0,122,255,0.15)', roof_scan:'rgba(255,149,0,0.15)', road_inspection:'rgba(142,142,147,0.15)', interior_scan:'rgba(52,199,89,0.15)', vegetation_survey:'rgba(48,209,88,0.15)' };
return m[type] || 'rgba(175,82,222,0.15)';
}
async function loadEarnings() {
const zoomerId = getZoomerId();
try {
const r = await fetch(`${API}/missions`);
const data = await r.json();
const all = data.missions || [];
// My missions with payment
const mine = all.filter(m => m.zoomer_id === zoomerId);
const approved = mine.filter(m => m.status === 'approved');
const pending = mine.filter(m => ['assigned','submitted'].includes(m.status));
const totalEarned = approved.reduce((s,m) => s + m.reward_sek, 0);
const totalPending = pending.reduce((s,m) => s + m.reward_sek, 0);
const avg = approved.length > 0 ? Math.round(totalEarned / approved.length) : 0;
// Update hero
document.getElementById('total-amount').innerHTML = `<span class="earnings-currency">SEK </span>${formatSEK(totalEarned)}`;
document.getElementById('earnings-sub').textContent = `${approved.length} approved missions · ${formatSEK(totalPending)} SEK pending`;
// Stats
document.getElementById('stat-uppdrag').textContent = approved.length;
document.getElementById('stat-snittersattning').textContent = avg > 0 ? formatSEK(avg) + ' kr' : '—';
document.getElementById('stat-pending').textContent = totalPending > 0 ? formatSEK(totalPending) + ' kr' : '—';
// Payouts list
const payoutsCard = document.getElementById('payouts-card');
const recent = [...approved].sort((a,b) => new Date(b.approved_at) - new Date(a.approved_at)).slice(0, 5);
if (recent.length === 0) {
payoutsCard.innerHTML = `
<div style="padding:32px;text-align:center">
<div style="font-size:40px;margin-bottom:8px"></div>
<div style="font-size:16px;font-weight:600;margin-bottom:4px">Inga utbetalningar än</div>
<div style="font-size:14px;color:var(--label-secondary)">Slutför ditt första uppdrag för att börja tjäna</div>
</div>`;
} else {
payoutsCard.innerHTML = recent.map(m => `
<div class="payout-row">
<div class="payout-icon" style="background:${missionTypeBg(m.mission_type)}">${missionTypeEmoji(m.mission_type)}</div>
<div class="payout-body">
<div class="payout-title">${m.title}</div>
<div class="payout-date">${formatDate(m.approved_at)} · ${m.payment_ref || '—'}</div>
</div>
<div class="payout-amount" style="color:var(--ios-green)">+${formatSEK(m.reward_sek)} kr</div>
</div>`).join('');
}
// Add pending items with different styling
if (pending.length > 0) {
const pendingHTML = pending.slice(0,3).map(m => `
<div class="payout-row" style="opacity:0.6">
<div class="payout-icon" style="background:rgba(255,149,0,0.15)">⏳</div>
<div class="payout-body">
<div class="payout-title">${m.title}</div>
<div class="payout-date">Väntande · ${m.status === 'submitted' ? 'Under granskning' : 'Pågående'}</div>
</div>
<div class="payout-amount" style="color:var(--ios-orange)">~${formatSEK(m.reward_sek)} kr</div>
</div>`).join('');
payoutsCard.innerHTML += pendingHTML;
}
} catch(e) {
console.error(e);
document.getElementById('payouts-card').innerHTML = `
<div style="padding:24px;text-align:center;color:var(--label-secondary)">
<div><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> Could not load earnings data</div>
</div>`;
}
}
async function loadConnectStatus() {
const statusEl = document.getElementById('connect-status');
const btnEl = document.getElementById('connect-btn');
// Try to get onboarding link to check if connect is available
try {
const r = await fetch(`${API}/payments/connect/onboarding-link`);
if (r.ok) {
const data = await r.json();
statusEl.innerHTML = `<div class="status-dot active"></div> Account activated — ready for payouts`;
btnEl.textContent = '<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> Hantera konto →';
if (data.url) { btnEl.dataset.url = data.url; }
} else if (r.status === 401) {
statusEl.innerHTML = `<div class="status-dot pending"></div> Inloggning krävs för att verifiera konto`;
} else {
statusEl.innerHTML = `<div class="status-dot inactive"></div> Konto ej konfigurerat`;
}
} catch {
statusEl.innerHTML = `<div class="status-dot inactive"></div> Konto ej konfigurerat — klicka för att börja`;
}
}
async function openConnectOnboarding() {
const btn = document.getElementById('connect-btn');
btn.textContent = '⏳ Öppnar...';
btn.disabled = true;
try {
const r = await fetch(`${API}/payments/connect/onboarding-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zoomer_id: getZoomerId() })
});
if (r.ok) {
const data = await r.json();
if (data.url) { window.open(data.url, '_blank'); }
else { toast('<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> Konto verifierat!'); }
} else if (r.status === 401) {
toast('<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1" stroke="#666" stroke-width="1.5"/><path d="M5 7V5C5 3.3 6.3 2 8 2C9.7 2 11 3.3 11 5V7" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="10.5" r="1" fill="#666"/></svg> Behörighet saknas — kontakta support');
} else {
toast('<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> Stripe Connect onboarding — kontakta support@quixzoom.com');
}
} catch {
toast(' Contact support to configure your payout account');
} finally {
btn.textContent = ' Configure payout account →';
btn.disabled = false;
}
}
// Init
loadEarnings();
loadConnectStatus();
</script>
</body>
</html>