Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code
ist einzeln rückverfolgbar von Scan bis Registrierung.
Backend:
- partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I),
users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by)
- /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect
/?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an
- Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten
Partner-Code gehört (Manipulationsschutz)
- Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen,
Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit
Kurz-URL + laufender Nummer), Code-Besitzer zuordnen
- Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer
Frontend:
- Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer)
- Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download
- boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit
Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung,
Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed.
205 lines
8.7 KiB
JavaScript
205 lines
8.7 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Boot-Phase
|
|
Offline-Banner + Service Worker Registration + Update-Flow
|
|
Extrahiert aus index.html für CSP-Härtung (kein unsafe-inline)
|
|
============================================================ */
|
|
|
|
// ----------------------------------------------------------
|
|
// Referral-Code aus ?ref= SOFORT in localStorage sichern — so früh wie möglich,
|
|
// bevor ein SW-Update-Reload die URL durch /?_t=... ersetzt und den Code verliert.
|
|
// localStorage (statt sessionStorage) überlebt auch App-Schließen/PWA-Neustart,
|
|
// sodass die Zuordnung auch klappt, wenn sich die Person erst später registriert.
|
|
// ----------------------------------------------------------
|
|
(function() {
|
|
try {
|
|
var rc = new URLSearchParams(location.search).get('ref');
|
|
if (rc) {
|
|
localStorage.setItem('by_ref_code', rc.toUpperCase());
|
|
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
|
}
|
|
// Partner-QR-Token (?qr= aus /q/{token}-Redirect) — Rückverfolgung pro Sticker/Flyer
|
|
var qt = new URLSearchParams(location.search).get('qr');
|
|
if (qt) localStorage.setItem('by_qr_token', qt);
|
|
// Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot
|
|
// die URL-Query strippt). Wird in ui.js Map.create ausgewertet.
|
|
var vm = new URLSearchParams(location.search).get('vectormap');
|
|
if (vm !== null) localStorage.setItem('by_vector_map', vm === '0' ? '0' : '1');
|
|
// MapLibre-GL-Karte (zentrale Karte) aus ?mapgl=1/0 — wird in pages/map.js _useGL() ausgewertet.
|
|
var mg = new URLSearchParams(location.search).get('mapgl');
|
|
if (mg !== null) localStorage.setItem('by_map_gl', mg === '0' ? '0' : '1');
|
|
// Offline-Vektorkacheln (byt://) aus ?tilesoffline=1/0 — ausgewertet via BY.offlineTiles().
|
|
var to = new URLSearchParams(location.search).get('tilesoffline');
|
|
if (to !== null) localStorage.setItem('by_offline_tiles', to === '0' ? '0' : '1');
|
|
} catch (e) {}
|
|
})();
|
|
|
|
// ----------------------------------------------------------
|
|
// Zentrale Feature-Flag-Helper (boot.js lädt vor allen Modulen)
|
|
// ----------------------------------------------------------
|
|
window.BY = window.BY || {};
|
|
// Offline-Vektorkacheln (byt://): Default AN auf allen deployten Hosts (Prod + Staging),
|
|
// localhost bleibt AUS; localStorage by_offline_tiles '1'/'0' bzw. ?tilesoffline übersteuert.
|
|
// Prod-Freigabe René 2026-06-07 (analog by_map_gl, Gerätetests Runde 1+2 bestanden).
|
|
window.BY.offlineTiles = function () {
|
|
try {
|
|
var flag = localStorage.getItem('by_offline_tiles');
|
|
if (flag === '1') return true;
|
|
if (flag === '0') return false;
|
|
return /(^|\.)banyaro\.(app|de)$/.test(location.hostname);
|
|
} catch (e) { return false; }
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// Offline-Banner
|
|
// ----------------------------------------------------------
|
|
(function() {
|
|
var _collapseTimer = null;
|
|
function _updateBanner() {
|
|
var banner = document.getElementById('offline-banner');
|
|
if (!banner) return;
|
|
clearTimeout(_collapseTimer);
|
|
banner.classList.remove('collapsed');
|
|
banner.style.display = navigator.onLine ? 'none' : 'flex';
|
|
// Nach 5s auf schmale Icon-Leiste einklappen — das volle Banner verdeckt
|
|
// sonst die Steuerung oben (z.B. Karten-Legende; Gerätetest iOS 2026-06-06).
|
|
if (!navigator.onLine) {
|
|
_collapseTimer = setTimeout(function() { banner.classList.add('collapsed'); }, 5000);
|
|
}
|
|
}
|
|
window.addEventListener('offline', function() {
|
|
_updateBanner();
|
|
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
|
|
if (!sessionStorage.getItem('by_offline_hint_shown')) {
|
|
sessionStorage.setItem('by_offline_hint_shown', '1');
|
|
setTimeout(function() {
|
|
window.UI?.toast?.info(
|
|
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
|
|
8000
|
|
);
|
|
}, 800);
|
|
}
|
|
// Queue-Count abfragen
|
|
if (navigator.serviceWorker) {
|
|
navigator.serviceWorker.ready.then(function(reg) {
|
|
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
|
|
});
|
|
}
|
|
});
|
|
window.addEventListener('online', function() {
|
|
_updateBanner();
|
|
var badge = document.getElementById('offline-queue-badge');
|
|
if (badge) badge.style.display = 'none';
|
|
// Queue abarbeiten
|
|
if (navigator.serviceWorker) {
|
|
navigator.serviceWorker.ready.then(function(reg) {
|
|
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
|
|
});
|
|
}
|
|
});
|
|
_updateBanner();
|
|
})();
|
|
|
|
// ----------------------------------------------------------
|
|
// Aufzeichnungs-Speicher (Sicherheitsnetz gegen Datenverlust bei Reload/Crash)
|
|
// ----------------------------------------------------------
|
|
window.RecStore = {
|
|
save: function(s) { try { localStorage.setItem('by_active_recording', JSON.stringify(Object.assign({ ts: Date.now() }, s))); } catch (e) {} },
|
|
load: function() { try { return JSON.parse(localStorage.getItem('by_active_recording') || 'null'); } catch (e) { return null; } },
|
|
clear: function() { try { localStorage.removeItem('by_active_recording'); } catch (e) {} },
|
|
};
|
|
|
|
// ----------------------------------------------------------
|
|
// SW-Reload — wird während einer laufenden Routen-Aufzeichnung AUFGESCHOBEN,
|
|
// damit der nur im RAM gehaltene Track nicht verloren geht. Sobald die
|
|
// Aufzeichnung beendet ist, holt window._byReloadIfPending() den Reload nach.
|
|
// ----------------------------------------------------------
|
|
window._byReloadIfPending = function() {
|
|
if (window._byReloadPending) {
|
|
window._byReloadPending = false;
|
|
window.location.replace('/?_t=' + Date.now());
|
|
}
|
|
};
|
|
function _bySwReload() {
|
|
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
|
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
|
|
return;
|
|
}
|
|
if (window._byRecording) { // Aufzeichnung läuft → Reload aufschieben
|
|
window._byReloadPending = true;
|
|
return;
|
|
}
|
|
window.location.replace('/?_t=' + Date.now());
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Service Worker Registration + Update-Flow
|
|
// ----------------------------------------------------------
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', function() {
|
|
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
|
.then(function(reg) {
|
|
function _watchSW(sw) {
|
|
if (!sw) return;
|
|
sw.addEventListener('statechange', function() {
|
|
if (sw.state === 'activated') _bySwReload();
|
|
});
|
|
}
|
|
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
|
|
if (reg.installing) _watchSW(reg.installing);
|
|
reg.update();
|
|
})
|
|
.catch(function(err) { console.warn('SW Registration failed:', err); });
|
|
});
|
|
|
|
// App aus dem Hintergrund: erneut prüfen
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.visibilityState === 'visible') {
|
|
navigator.serviceWorker.getRegistration().then(function(reg) { if (reg) reg.update(); });
|
|
}
|
|
});
|
|
|
|
// Backup: controllerchange falls updatefound nicht feuert
|
|
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
|
|
if (!window._BY_SW_RELOAD) {
|
|
navigator.serviceWorker.addEventListener('controllerchange', function() {
|
|
_bySwReload();
|
|
});
|
|
}
|
|
|
|
navigator.serviceWorker.addEventListener('message', function(e) {
|
|
if (e.data && e.data.type === 'QUEUE_PROCESSED') {
|
|
var synced = e.data.synced, failed = e.data.failed, total = e.data.total;
|
|
if (total === 0) return;
|
|
if (synced > 0 && window.UI && window.UI.toast) {
|
|
window.UI.toast.success(
|
|
synced === 1
|
|
? '1 offline gespeicherter Eintrag synchronisiert'
|
|
: synced + ' offline gespeicherte Einträge synchronisiert'
|
|
);
|
|
if (window.App && window.App.state && window.pages) {
|
|
var p = window.pages[window.App.state.page];
|
|
if (p && p.module && p.module.refresh) p.module.refresh();
|
|
}
|
|
}
|
|
if (failed > 0 && window.UI && window.UI.toast) {
|
|
window.UI.toast.warning(failed + ' Eintrag' + (failed > 1 ? 'e' : '') + ' noch nicht synchronisiert — kein Netz');
|
|
}
|
|
return;
|
|
}
|
|
if (e.data && e.data.type === 'QUEUE_COUNT') {
|
|
var badge = document.getElementById('offline-queue-badge');
|
|
if (badge) {
|
|
if (e.data.count > 0) {
|
|
badge.textContent = e.data.count;
|
|
badge.style.display = '';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (e.data && e.data.type === 'CHECK_NEARBY_ALERTS') {
|
|
if (window.App && window.App._checkNearbyAlerts) window.App._checkNearbyAlerts();
|
|
}
|
|
});
|
|
}
|