diff --git a/backend/database.py b/backend/database.py index f5aee7b..7368eb7 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2398,6 +2398,65 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + # Rechnungs-System + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_number TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id), + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + recipient_address TEXT, + description TEXT NOT NULL, + service_period TEXT, + amount_net REAL NOT NULL, + discount_pct REAL DEFAULT 0, + discount_amount REAL DEFAULT 0, + amount_after_discount REAL NOT NULL, + tax_rate REAL DEFAULT 0, + tax_amount REAL DEFAULT 0, + amount_gross REAL NOT NULL, + status TEXT DEFAULT 'draft', + notes TEXT, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + sent_at TEXT, + paid_at TEXT, + paid_amount REAL, + cancelled_at TEXT, + cancellation_reason TEXT, + cancellation_number TEXT + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)") + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 1, + unit_price REAL NOT NULL, + total REAL NOT NULL + ) + """) + logger.info("Migration: invoices + invoice_items bereit.") + except Exception as e: + logger.warning(f"Migration invoices: {e}") + + try: + conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT") + logger.info("Migration: billing_address bereit.") + except Exception: + pass + + existing_u_gb = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'geburtstag' not in existing_u_gb: + conn.execute("ALTER TABLE users ADD COLUMN geburtstag TEXT") + logger.info("Migration: users.geburtstag hinzugefügt.") + else: + logger.info("Migration: users.geburtstag bereits vorhanden.") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/mailer.py b/backend/mailer.py index 344fe4f..5c47ee1 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -5,12 +5,18 @@ Unterstützt zwei Backends (wird automatisch gewählt): 2. SMTP — wenn SMTP_HOST gesetzt (Fallback) """ +import imaplib import os +import base64 import smtplib import asyncio import logging +import ssl +from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication +from email.utils import formatdate import httpx @@ -24,18 +30,77 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email" SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USER = os.getenv("SMTP_USER", "") -SMTP_PASS = os.getenv("SMTP_PASS", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "") + +# IMAP für Gesendet-Ordner +IMAP_HOST = os.getenv("IMAP_HOST", SMTP_HOST) +IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) +_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"] SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro ") APP_URL = os.getenv("APP_URL", "https://banyaro.app") +# ------------------------------------------------------------------ +# IMAP: Mail in Gesendet-Ordner speichern +# ------------------------------------------------------------------ +def _imap_save_sent(msg_bytes: bytes): + if not IMAP_HOST or not SMTP_USER or not SMTP_PASS: + return + try: + ctx = ssl.create_default_context() + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=ctx) as imap: + imap.login(SMTP_USER, SMTP_PASS) + _, raw_folders = imap.list() + available = [f.decode(errors="replace") for f in (raw_folders or [])] + folder = None + for line in available: + name = line.rsplit('"." ', 1)[-1].strip().strip('"') + for candidate in _SENT_CANDIDATES: + if candidate.lower() in name.lower(): + folder = name + break + if folder: + break + if not folder: + folder = "INBOX.Sent" + imap.append( + folder, + r"\Seen", + imaplib.Time2Internaldate(datetime.now().timestamp()), + msg_bytes, + ) + except Exception as e: + logger.warning(f"IMAP Gesendet-Speicherung fehlgeschlagen: {e}") + + +def _build_mime_copy(to: str, subject: str, html: str, plain: str, attachments: list | None) -> MIMEMultipart: + """Baut eine MIME-Nachricht für die Gesendet-Ablage (Brevo-Pfad).""" + if attachments: + msg = MIMEMultipart("mixed") + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(plain, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + msg.attach(alt) + for a in attachments: + part = MIMEApplication(a["content"], Name=a["filename"]) + part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"' + msg.attach(part) + else: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = to + msg["Date"] = formatdate(localtime=False) + return msg + + # ------------------------------------------------------------------ # Brevo REST-API # ------------------------------------------------------------------ -async def _send_brevo(to: str, subject: str, html: str, plain: str): - # Absender-Name und -Adresse aus SMTP_FROM parsen - # Format: "Ban Yaro " oder "noreply@banyaro.app" +async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None): from_raw = SMTP_FROM if "<" in from_raw: from_name = from_raw[:from_raw.index("<")].strip() @@ -52,6 +117,14 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): "textContent": plain, "headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"}, } + if attachments: + payload["attachment"] = [ + { + "name": a["filename"], + "content": base64.b64encode(a["content"]).decode("ascii"), + } + for a in attachments + ] async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( BREVO_API_URL, @@ -64,30 +137,50 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): # ------------------------------------------------------------------ # SMTP Fallback # ------------------------------------------------------------------ -def _send_smtp_sync(to: str, subject: str, html: str, plain: str): - msg = MIMEMultipart("alternative") +def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None): + if attachments: + msg = MIMEMultipart("mixed") + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(plain, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + msg.attach(alt) + for a in attachments: + part = MIMEApplication(a["content"], Name=a["filename"]) + part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"' + msg.attach(part) + else: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + msg["Subject"] = subject msg["From"] = SMTP_FROM msg["To"] = to - msg.attach(MIMEText(plain, "plain", "utf-8")) - msg.attach(MIMEText(html, "html", "utf-8")) + msg["Date"] = formatdate(localtime=False) + msg_bytes = msg.as_bytes() with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: s.ehlo() s.starttls() if SMTP_USER: s.login(SMTP_USER, SMTP_PASS) - s.sendmail(SMTP_FROM, [to], msg.as_string()) + s.sendmail(SMTP_FROM, [to], msg_bytes) + _imap_save_sent(msg_bytes) # ------------------------------------------------------------------ # Öffentliche Funktion # ------------------------------------------------------------------ -async def send_email(to: str, subject: str, html: str, plain: str = ""): +async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): if BREVO_API_KEY: try: - await _send_brevo(to, subject, html, plain) + await _send_brevo(to, subject, html, plain, attachments) logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") + # MIME-Kopie für Gesendet-Ordner konstruieren + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: _imap_save_sent( + _build_mime_copy(to, subject, html, plain, attachments).as_bytes() + )) return except Exception as e: logger.error(f"Brevo-Fehler: {e}") @@ -96,7 +189,9 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): if SMTP_HOST: loop = asyncio.get_event_loop() try: - await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) + await loop.run_in_executor( + None, _send_smtp_sync, to, subject, html, plain, attachments + ) logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") return except Exception as e: diff --git a/backend/main.py b/backend/main.py index f1025de..d63ae64 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,6 +253,8 @@ from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router from routes.feedback import router as feedback_router +from routes.contact import router as contact_router +from routes.invoices import router as invoices_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -317,6 +319,8 @@ app.include_router(challenges_router, prefix="/api/challenges", ta app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) +app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"]) +app.include_router(invoices_router) # ------------------------------------------------------------------ @@ -406,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -1720,8 +1724,8 @@ async def force_update(): height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px} p{color:#94a3b8;font-size:14px} -
⏳ Aktualisiere Ban Yaro…
-

Service Worker wird entfernt…

+
⏳ Einen Moment…
+

Wir besorgen neue Leckerlis 🦴

- - - + + + @@ -296,6 +296,7 @@
Impressum Datenschutz + AGB
+
+
+
+
@@ -487,6 +492,10 @@
+
+
+
+
@@ -503,6 +512,14 @@
+
+
+
+ +
+
+
+
@@ -599,10 +616,10 @@ - - - - + + + + @@ -620,6 +637,16 @@ } 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) { @@ -678,7 +705,7 @@ // Backup: controllerchange (falls updatefound nicht feuert) // NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL) // — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert - if (!location.search.includes('_t=')) { + if (!window._BY_SW_RELOAD) { navigator.serviceWorker.addEventListener('controllerchange', () => { if (sessionStorage.getItem('by_skip_sw_reload')) { sessionStorage.removeItem('by_skip_sw_reload'); diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6594b45..1bec4db 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,11 +3,13 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; -// Cache-Bust-Parameter nach Update-Reload sofort entfernen -if (location.search.includes('_t=')) history.replaceState(null, '', '/'); +// Cache-Bust-Parameter nach Update-Reload sofort entfernen. +// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach. +window._BY_SW_RELOAD = location.search.includes('_t='); +if (window._BY_SW_RELOAD) history.replaceState(null, '', '/'); const App = (() => { @@ -64,15 +66,19 @@ const App = (() => { moderation: { title: 'Moderation', module: null, requiresAuth: true }, impressum: { title: 'Impressum', module: null }, datenschutz: { title: 'Datenschutz', module: null }, + agb: { title: 'AGB', module: null }, widget: { title: 'Widget', module: null, requiresAuth: true }, notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, - breeder: { title: 'Züchter-Profil', module: null }, - litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, + breeder: { title: 'Züchter-Profil', module: null }, + 'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true }, + litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, wurfboerse: { title: 'Wurfbörse', module: null }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, gruender: { title: '100 Gründer', module: null }, + partner: { title: 'Unsere Partner', module: null }, + 'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true }, jobs: { title: 'Wir suchen dich', module: null }, expenses: { title: 'Ausgaben', module: null, requiresAuth: true }, recalls: { title: 'Rückrufe', module: null }, @@ -255,6 +261,8 @@ const App = (() => { if (mod?.init) { await mod.init(container, state, params); page.module = mod; + // Desktop: erste Inhalts-Div auf Standardbreite setzen + _applyDesktopWidth(pageId, container); } else { // Platzhalter wenn Seite noch nicht gebaut container.innerHTML = UI.emptyState({ @@ -265,10 +273,13 @@ const App = (() => { page.module = {}; // verhindert erneutes Laden } } catch { + const _offline = !navigator.onLine; container.innerHTML = UI.emptyState({ - icon: '🚧', + icon: _offline ? '📡' : '🚧', title: pages[pageId].title, - text: 'Diese Seite ist noch in Entwicklung.', + text: _offline + ? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.' + : 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; } finally { @@ -276,6 +287,23 @@ const App = (() => { } } + // ---------------------------------------------------------- + // DESKTOP WIDTH — einheitliche Breite auf großen Screens + // ---------------------------------------------------------- + const _FULLSCREEN_PAGES = new Set([ + 'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse', + 'routes','walks','litters','zucht-profil','widget', + ]); + function _applyDesktopWidth(pageId, container) { + if (window.innerWidth < 768) return; + if (_FULLSCREEN_PAGES.has(pageId)) return; + const first = container.querySelector(':scope > div'); + if (first && !first.classList.contains('page-container') && + !first.classList.contains('pc-desktop')) { + first.classList.add('pc-desktop'); + } + } + // ---------------------------------------------------------- // LOGIN GATE — wird statt Seiteninhalt angezeigt // ---------------------------------------------------------- @@ -585,11 +613,16 @@ const App = (() => { _checkNearbyAlerts(); setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); setInterval(_checkNearbyAlerts, 5 * 60_000); + // App-Heartbeat: last_seen aktualisieren (Nutzungsfrequenz für Admin) + const _sendHeartbeat = () => API.post('/auth/heartbeat', {}).catch(() => {}); + _sendHeartbeat(); + setInterval(_sendHeartbeat, 5 * 60_000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); + _sendHeartbeat(); if (state.page === 'chat') { pages['chat']?.module?.refresh?.(); } @@ -1140,6 +1173,21 @@ const App = (() => { window.App = App; // Worlds kann App.navigate() aufrufen // App starten +// Prioritäts-Seiten im Hintergrund vorladen (1s nach Start) +window.addEventListener('load', () => { + setTimeout(() => { + if (!navigator.onLine) return; + // Page-Scripts cachen + [ + 'admin','erste-hilfe','diary','map','walks','routes','poison','lost', + 'expenses','wetter','forum','health','uebungen','trainingsplaene','notes', + ].forEach(page => { + const key = `Page_${page.replace(/-/g,'_')}`; + if (!window[key]) fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {}); + }); + }, 1000); +}); + document.addEventListener('DOMContentLoaded', () => { App.init(); if (IS_STAGING) { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 8d8e780..b7acbf5 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -27,6 +27,7 @@ window.Page_admin = (() => { { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, + { id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' }, ]; // ------------------------------------------------------------------ @@ -55,17 +56,17 @@ window.Page_admin = (() => {
- -
- ${TABS.map(t => ` - - `).join('')} + +
+
+ ${TABS.map(t => ` + + `).join('')} +
+
- - -
`; _container.querySelector('#adm-tabs') @@ -97,6 +98,7 @@ window.Page_admin = (() => { { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, + { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' }, ]; const open = items.filter(i => d[i.key] > 0); @@ -166,6 +168,7 @@ window.Page_admin = (() => { case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; case 'upgrades': await _renderUpgrades(el); break; + case 'rechnungen': await _renderRechnungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -3528,8 +3531,14 @@ window.Page_admin = (() => {
${_esc(r.email)}
${tierBadge(r.tier)} + ${r.discount_pct > 0 ? ` + ${r.discount_pct}% Rabatt` : ''} ${r.created_at?.slice(0,10) || ''}
+ ${r.discount_reason === 'founder' ? `
Gründer — kostenfrei
` : ''} + ${r.discount_reason === 'referred_by_founder' ? `
Von Gründer eingeladen
` : ''} + ${r.discount_reason === 'referral' ? `
${r.referral_count} Freunde geworben
` : ''} ${r.message ? `
@@ -3537,12 +3546,25 @@ window.Page_admin = (() => {
` : ''}
- +
+ + +
`; // Erledigte als kompakte Tabellenzeilen @@ -3588,7 +3610,7 @@ window.Page_admin = (() => { const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier; const ok = await UI.modal.confirm({ title: `${name} auf ${tierLabel} freischalten?`, - message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`, + message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`, confirmText: 'Freischalten', danger: false, }); @@ -3597,7 +3619,14 @@ window.Page_admin = (() => { btn.textContent = '…'; try { const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); - UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + if (res.invoice_number) { + UI.toast.success( + `${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`, + 6000 + ); + } else { + UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + } _renderTab(); _renderActionItems(); } catch (e) { @@ -3607,6 +3636,867 @@ window.Page_admin = (() => { } }); }); + + // "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten + const TIER_ITEMS = { + pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 }, + breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 }, + }; + const _year = new Date().getFullYear(); + const _now = new Date(); + const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1); + const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; + const _period = `${_fmt(_now)} - ${_fmt(_end)}`; + + function _discountNote(reason, count, pct, tierLabel) { + const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.'; + if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`; + if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`; + if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`; + return agb; + } + + el.querySelectorAll('.adm-invoice-btn').forEach(btn => { + btn.addEventListener('click', () => { + const { name, email, tier, address } = btn.dataset; + const discountPct = Number(btn.dataset.discount) || 0; + const discountReason = btn.dataset.discountReason || ''; + const referralCount = Number(btn.dataset.referralCount) || 0; + const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 }; + _openNeueRechnungModal(() => { + _tab = 'rechnungen'; + _renderTab(); + }, { + recipient_name: name, + recipient_email: email, + recipient_address: address || '', + service_period: _period, + discount_pct: discountPct, + notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description), + items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }], + }); + }); + }); + } + + // ------------------------------------------------------------------ + // TAB: RECHNUNGEN + // ------------------------------------------------------------------ + async function _renderRechnungen(el) { + let _subView = 'liste'; // 'liste' | 'cashflow' + + async function _load() { + el.innerHTML = ` +
+
+ + +
+ ${_subView === 'liste' ? ` + ` : ''} +
+
+
Lade…
+
+ `; + + el.querySelectorAll('.adm-inv-nav').forEach(btn => { + btn.addEventListener('click', () => { + _subView = btn.dataset.v; + _load(); + }); + }); + + el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load)); + + const content = el.querySelector('#adm-inv-content'); + if (_subView === 'liste') { + await _loadInvoiceList(content, _load); + } else { + await _loadCashflow(content); + } + } + + await _load(); + } + + async function _loadInvoiceList(el, reload) { + let invoices; + try { + invoices = await API.get('/admin/invoices'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.'); + return; + } + + if (!invoices.length) { + el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.'); + return; + } + + const _statusBadge = status => { + const cfg = { + draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'], + sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'], + paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'], + cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'], + }; + const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)']; + return `${label}`; + }; + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const rows = invoices.map((inv, i) => { + const actions = []; + if (inv.status === 'draft') { + actions.push(``); + actions.push(``); + } + if (inv.status === 'sent') { + actions.push(``); + } + if (inv.status === 'sent') { + actions.push(``); + actions.push(``); + } + if (inv.status === 'paid' || inv.status === 'cancelled') { + actions.push(``); + } + + return ` + + + ${_esc(inv.invoice_number)} + + +
${_esc(inv.recipient_name)}
+
${_esc(inv.recipient_email || '')}
+ + + ${_fmtEur(inv.amount_gross)} + ${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01 + ? `
+ erhalten: ${_fmtEur(inv.paid_amount)} + ${inv.paid_amount < inv.amount_gross + ? `-${_fmtEur(inv.amount_gross - inv.paid_amount)}` + : ''} +
` + : ''} + + ${_statusBadge(inv.status)} + + ${_fmtDate(inv.created_at)} + + +
${actions.join('')}
+ + `; + }).join(''); + + el.innerHTML = ` +
+
+ + + + + + + + + + + + ${rows} +
NummerEmpfängerBetragStatusErstellt
+
+
+ `; + + // Senden + el.querySelectorAll('.adm-inv-send').forEach(btn => { + btn.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: `Rechnung ${btn.dataset.num} versenden?`, + message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.', + confirmText: 'Jetzt versenden', + }); + if (!ok) return; + btn.disabled = true; + try { + await API.post(`/admin/invoices/${btn.dataset.id}/send`, {}); + UI.toast.success('Rechnung versendet.'); + reload(); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Versenden.'); + btn.disabled = false; + } + }); + }); + + // Entwurf bearbeiten + el.querySelectorAll('.adm-inv-edit').forEach(btn => { + btn.addEventListener('click', async () => { + const inv = await API.get(`/admin/invoices/${btn.dataset.id}`); + _openNeueRechnungModal(reload, { + recipient_name: inv.recipient_name, + recipient_email: inv.recipient_email, + recipient_address: inv.recipient_address || '', + service_period: inv.service_period || '', + discount_pct: inv.discount_pct || 0, + notes: inv.notes || '', + items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })), + }, inv.id); + }); + }); + + // Als bezahlt markieren + el.querySelectorAll('.adm-inv-pay').forEach(btn => { + btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload)); + }); + + // Stornieren + el.querySelectorAll('.adm-inv-cancel').forEach(btn => { + btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload)); + }); + + // Details + el.querySelectorAll('.adm-inv-detail').forEach(btn => { + btn.addEventListener('click', () => _openDetailModal(btn.dataset.id)); + }); + } + + function _openNeueRechnungModal(reload, prefill = null, invoiceId = null) { + const id = `inv-new-${Date.now()}`; + const p = prefill || {}; + const isEdit = !!invoiceId; + + UI.modal.open({ + title: `${UI.icon('receipt')} ${isEdit ? 'Rechnung bearbeiten' : 'Neue Rechnung erstellen'}`, + body: ` +
+ + ${!isEdit && !p.recipient_name ? ` +
+ Diese Rechnung ist für sonstige Leistungen (Beratung, Einmalleistung etc.).
+ Für Abo-Verlängerungen bitte den Button „Rechnung erstellen" in der Upgrades-Liste verwenden. +
` : ''} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ Netto: — +
+
+ +
+ + +
+ +
+ `, + footer: ` + + + `, + }); + + // Items-Container und Hilfsfunktionen + const itemsContainer = document.getElementById(`${id}-items`); + const previewEl = document.getElementById(`${id}-preview`); + const discountEl = document.getElementById(`${id}-discount`); + + function _addItem(desc = '', qty = 1, price = 0) { + const itemEl = document.createElement('div'); + itemEl.className = 'adm-inv-item-row'; + itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center'; + itemEl.innerHTML = ` + + + + + `; + itemEl.querySelector('.inv-item-remove').addEventListener('click', () => { + if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) { + itemEl.remove(); + _updatePreview(); + } + }); + itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview)); + itemsContainer.appendChild(itemEl); + _updatePreview(); + } + + function _updatePreview() { + let netto = 0; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + netto += qty * price; + }); + const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0)); + const rabatt = netto * disc / 100; + const brutto = netto - rabatt; + previewEl.innerHTML = ` + Netto: + ${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + ${disc > 0 ? ` · -${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)` : ''} +  · Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + `; + } + + // Erste Position — aus Prefill oder Standard + if (p.items && p.items.length) { + p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0)); + } else { + _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); + } + + // Weitere Position + document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem()); + discountEl?.addEventListener('input', _updatePreview); + + // Form Submit + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const items = []; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const desc = row.querySelector('.inv-item-desc').value.trim(); + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + if (desc) items.push({ description: desc, quantity: qty, unit_price: price }); + }); + if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; } + + const submitBtn = e.target.closest('.modal-content, [id]') + ? document.querySelector(`button[form="${id}"]`) + : null; + if (submitBtn) submitBtn.disabled = true; + + try { + const payload = { + recipient_name: fd.get('recipient_name'), + recipient_email: fd.get('recipient_email') || null, + recipient_address: fd.get('recipient_address') || null, + service_period: fd.get('service_period') || null, + discount_pct: parseFloat(fd.get('discount_pct')) || 0, + notes: fd.get('notes') || null, + items, + }; + if (isEdit) { + await API.patch(`/admin/invoices/${invoiceId}`, payload); + } else { + await API.post('/admin/invoices', payload); + } + UI.modal.close(); + UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Erstellen.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openBezahltModal(invoiceId, defaultAmount, reload) { + const today = new Date().toISOString().slice(0, 10); + const id = `inv-pay-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('check-circle')} Als bezahlt markieren`, + body: ` +
+
+ + +
+
+ + +
+ +
+ `, + footer: ` + + + `, + }); + + // Differenz live anzeigen + const amtEl = document.getElementById(`${id}-amt`); + const diffEl = document.getElementById(`${id}-diff`); + const _checkDiff = () => { + const entered = parseFloat(amtEl?.value) || 0; + const diff = defaultAmount - entered; + if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; } + diffEl.style.display = 'block'; + if (diff > 0) { + diffEl.innerHTML = `Differenz: -${diff.toFixed(2)} € weniger als fakturiert.
+ `; + } else { + diffEl.innerHTML = `Überzahlung: +${(-diff).toFixed(2)} € mehr eingegangen.`; + diffEl.style.background = '#f0fff8'; + diffEl.style.borderColor = '#34d399'; + diffEl.style.color = '#065f46'; + } + }; + amtEl?.addEventListener('input', _checkDiff); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const paidAmount = parseFloat(fd.get('paid_amount')); + const diff = defaultAmount - paidAmount; + const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked; + + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + const kulanzNote = kulanz + ? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.` + : null; + await API.post(`/admin/invoices/${invoiceId}/pay`, { + paid_at: fd.get('paid_at'), + paid_amount: paidAmount, + ...(kulanzNote ? { notes: kulanzNote } : {}), + }); + UI.modal.close(); + UI.toast.success(kulanz + ? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.` + : 'Rechnung als bezahlt markiert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openStornoModal(invoiceId, invoiceNum, reload) { + const id = `inv-cancel-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('x-circle')} Rechnung stornieren`, + body: ` +
+

+ Rechnung ${_esc(invoiceNum)} stornieren. +

+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const reason = (fd.get('reason') || '').trim(); + if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; } + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason }); + UI.modal.close(); + UI.toast.success('Rechnung storniert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + async function _openDetailModal(invoiceId) { + let inv; + try { + inv = await API.get(`/admin/invoices/${invoiceId}`); + } catch (e) { + UI.toast.error(e.message || 'Detail konnte nicht geladen werden.'); + return; + } + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusColors = { + draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', + paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)', + }; + const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + + const itemsHtml = (inv.items || []).map(item => ` + + ${_esc(item.description)} + ${item.quantity} + ${_fmtEur(item.unit_price)} + ${_fmtEur(item.total)} + + `).join(''); + + UI.modal.open({ + title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`, + body: ` +
+
+
+
Empfänger
+
${_esc(inv.recipient_name)}
+ ${inv.recipient_email ? `
${_esc(inv.recipient_email)}
` : ''} + ${inv.recipient_address ? `
${_esc(inv.recipient_address)}
` : ''} +
+
+
Status
+
${statusLabels[inv.status] || inv.status}
+
+ Erstellt: ${_fmtDate(inv.created_at)}
+ ${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}
` : ''} + ${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}
` : ''} +
+
+
+ + ${inv.service_period ? ` +
+
Leistungszeitraum
+
${_esc(inv.service_period)}
+
` : ''} + + +
+
Positionen
+ + + + + + + + + + ${itemsHtml} + + + + + + +
BeschreibungMengePreisGesamt
Gesamt (brutto)${_fmtEur(inv.amount_gross)}
+
+ + ${inv.notes ? ` +
+
Notizen
+
${_esc(inv.notes)}
+
` : ''} +
+ `, + footer: ``, + }); + } + + async function _loadCashflow(el) { + let cf; + try { + cf = await API.get('/admin/invoices/cashflow'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.'); + return; + } + + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' }; + + const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => ` +
+
${n}
+
${statusLabels[s] || s}
+
`).join(''); + + const monthRows = (cf.monthly || []).map((m, i) => ` + + ${_esc(m.month)} + ${m.count} + ${_fmtEur(m.revenue)} + `).join(''); + + // Quartalsbericht-Download + const currentYear = new Date().getFullYear(); + const years = [currentYear, currentYear - 1].map(y => ``).join(''); + + el.innerHTML = ` + +
+
+
${_fmtEur(cf.total_paid)}
+
Einnahmen (bezahlt)
+
+
+
${_fmtEur(cf.total_outstanding)}
+
Offene Forderungen
+
+
+
${_fmtEur(cf.total_year)}
+
Jahresumsatz gesamt
+
+ ${countKacheln} +
+ + +
+
Monatliche Übersicht
+
+ + + + + + + + + + ${monthRows || ``} + +
MonatRechnungenUmsatz
Keine Daten
+
+
+ + +
+
+ ${UI.icon('file-csv')} Quartalsbericht herunterladen +
+
+
+ + +
+
+ + +
+ + +
+
+
+ `; + + // CSV Download + el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; } + + // CSV generieren + const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00'; + const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + + const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' }; + const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n'; + const csvRows = data.invoices.map(inv => { + const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; + return [ + inv.invoice_number, + inv.recipient_name, inv.recipient_email || '', + fmtDate(inv.created_at), inv.service_period || '', + fmtEur(effectiveAmt), + fmtEur(inv.amount_gross), + statusLabel[inv.status] || inv.status, + fmtDate(inv.sent_at), fmtDate(inv.paid_at) + ].map(escape).join(';'); + }).join('\n'); + + const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `banyaro-rechnungen-${year}-Q${q}.csv`; + a.click(); + URL.revokeObjectURL(url); + UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Laden.'); + } + }); + + // Quartals-Vorschau + el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + const resultEl = el.querySelector('#adm-inv-q-result'); + resultEl.innerHTML = '
Lade…
'; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { + resultEl.innerHTML = `
Keine Rechnungen in ${data.period || `Q${q} ${year}`}.
`; + return; + } + const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—'; + const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—'; + const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' }; + const rows2 = data.invoices.map((inv, i) => { + const isStorno = inv.status === 'storno'; + const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; + const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : ''); + const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01) + ? ` (RG: ${_fmtE(inv.amount_gross)})` : ''; + return ` + + ${_esc(inv.invoice_number)} + ${_esc(inv.recipient_name)} + ${_fmtE(effectiveAmt)}${amtNote} + ${sL[inv.status]||inv.status} + ${_fmtD(inv.created_at)} + `; + }).join(''); + resultEl.innerHTML = ` +
+ ${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)} +
+
+ + + + + + + ${rows2} + + + + + +
NummerEmpfängerBetragStatusErstellt
Gesamt${_fmtE(data.total_gross)} + Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)} +
+
`; + } catch (e) { + resultEl.innerHTML = `
Fehler: ${_esc(e.message)}
`; + } + }); } return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/agb.js b/backend/static/js/pages/agb.js new file mode 100644 index 0000000..9a1c2e2 --- /dev/null +++ b/backend/static/js/pages/agb.js @@ -0,0 +1,195 @@ +/* ============================================================ + BAN YARO — Allgemeine Geschäftsbedingungen + ============================================================ */ + +window.Page_agb = (() => { + + const S = { + h2: `font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-primary);margin:0 0 var(--space-2)`, + p: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0`, + ul: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:var(--space-2) 0 0;padding-left:var(--space-5)`, + a: `color:var(--c-primary)`, + }; + + function sec(title, body) { + return ` +
+

${title}

+ ${body} +
`; + } + + function init(container) { + container.innerHTML = ` +
+ +

Allgemeine Geschäftsbedingungen

+

Gültig ab Mai 2026

+ + ${sec('1. Geltungsbereich', ` +

+ Diese AGB gelten für die Nutzung der Plattform Ban Yaro + (banyaro.app), betrieben von:

+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app +

+

+ Sie gelten ausschließlich für kostenpflichtige Abonnements. Die kostenlose Nutzung + der App setzt lediglich die Registrierung voraus. +

`)} + + ${sec('2. Mindestalter', ` +

+ Die Nutzung von Ban Yaro, insbesondere die Registrierung und der Abschluss eines + Abonnements, ist nur Personen ab 18 Jahren gestattet. Mit Abschluss des Vertrags + bestätigt der Nutzer, volljährig zu sein. +

`)} + + ${sec('3. Leistungen', ` +

Ban Yaro bietet folgende kostenpflichtige Abonnements an:

+
    +
  • + Ban Yaro Pro — 29 EUR/Jahr: Erweiterte App-Funktionen für mehrere + Hunde, KI-Features, zusätzliche Karten-Layer, Chat und Playdate-Funktion sowie + alle weiteren Pro-Funktionen laut aktuellem Funktionsumfang. +
  • +
  • + Ban Yaro Züchter — 49 EUR/Jahr: Alle Pro-Funktionen plus + Zuchtkartei, Stammbaum, Wurfverwaltung und Züchterprofil. +
  • +
+

+ Änderungen am Funktionsumfang werden vorab per E-Mail angekündigt. Wesentliche + Leistungsminderungen berechtigen zur außerordentlichen Kündigung. +

`)} + + ${sec('4. Nutzungsregeln / Community', ` +

Die Nutzung der Plattform-Features (Forum, Chat, Fotos, Kommentare) unterliegt folgenden Regeln:

+
    +
  • Keine rechtswidrigen, beleidigenden, diskriminierenden oder irreführenden Inhalte
  • +
  • Kein Spam, keine Werbung ohne Genehmigung, keine Fake-Accounts
  • +
  • Respektvoller Umgang mit anderen Nutzern
  • +
  • Keine Verletzung von Urheberrechten Dritter bei hochgeladenen Inhalten
  • +
+

+ Bei Verstoß sind wir berechtigt, Inhalte zu entfernen und Accounts zu sperren oder + zu kündigen. Rechtswidrige Inhalte werden unverzüglich entfernt und ggf. Behörden + gemeldet. Meldungen können an + hallo@banyaro.app + gerichtet werden. +

`)} + + ${sec('5. Nutzerinhalte und Lizenzen', ` +

+ Durch das Hochladen von Inhalten (Fotos, Texte, Beiträge) räumt der Nutzer Ban Yaro + eine nicht-exklusive, kostenlose, weltweite Lizenz ein, diese Inhalte im Rahmen der + Plattform zu speichern, anzuzeigen und technisch zu verarbeiten. Diese Lizenz erlischt + mit Löschung des Inhalts oder Löschung des Accounts. Urheberrechte und sonstige + Rechte der Nutzer an ihren Inhalten bleiben unberührt. +

`)} + + ${sec('6. Preise und Zahlung', ` +

+ Der Jahresbeitrag ist bei Vertragsschluss für die gesamte Laufzeit im Voraus fällig. + Die Zahlung erfolgt per Überweisung — IBAN und Verwendungszweck stehen auf der + Rechnung, die per E-Mail zugestellt wird. Der Betrag ist innerhalb von + 14 Tagen nach Rechnungsstellung zu überweisen. +

+

+ Bei Zahlungsverzug erhalten Sie zunächst eine Zahlungserinnerung. Bleibt der Betrag + danach weiterhin ausstehend, behalten wir uns die fristlose Kündigung des Vertrags + gemäß § 314 BGB vor. +

`)} + + ${sec('7. Vertragslaufzeit und Kündigung', ` +

+ Die Erstlaufzeit beträgt 12 Monate ab dem Tag der Freischaltung. + Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit — kündbar jederzeit + mit einer Frist von einem Monat zum Monatsende (§ 309 Nr. 9 BGB). +

+

+ Die Kündigung ist jederzeit in den App-Einstellungen unter + Einstellungen → Abonnement → Kündigen möglich (§ 312k BGB). + Eine Kündigungsbestätigung wird per E-Mail zugesandt. + Der Zugang bleibt bis zum Ende der bereits bezahlten Laufzeit vollständig aktiv. +

`)} + + ${sec('8. Kein Erstattungsanspruch', ` +

+ Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine anteilige Rückerstattung + des Jahresbeitrags. Der Zugang bleibt bis zum Ende der Laufzeit vollständig nutzbar — + du verlierst also nichts, was du bereits bezahlt hast. + Gesetzliche Ansprüche bei vertragswidrigen Leistungen bleiben unberührt. +

`)} + + ${sec('9. Widerrufsrecht', ` +

+ Da die Nutzung unmittelbar nach Freischaltung beginnt und du beim Kauf ausdrücklich + zustimmst, dass die Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt, erlischt + dein 14-tägiges Widerrufsrecht mit Beginn der Nutzung (§ 356 Abs. 4 BGB). Dir ist + bekannt, dass du durch diese Zustimmung dein Widerrufsrecht verlierst. Die Zustimmung + wird beim Kauf aktiv protokolliert. +

`)} + + ${sec('10. Fristlose Kündigung durch den Anbieter', ` +

+ Wir sind berechtigt, den Vertrag aus wichtigem Grund fristlos zu kündigen + (§ 314 BGB). Ein wichtiger Grund liegt insbesondere vor, wenn nach einer + Zahlungserinnerung der offene Betrag weiterhin nicht beglichen wird. + In diesem Fall endet der Zugang mit Wirkung der Kündigung. +

`)} + + ${sec('11. KI-Funktionen / Haftung für KI-Inhalte', ` +

+ KI-generierte Inhalte (Trainer-Empfehlungen, Gesundheitshinweise, Züchter-Analysen) + können fehlerhaft oder unvollständig sein. Sie dienen ausschließlich der allgemeinen + Information und ersetzen keine tierärztliche, veterinärmedizinische oder fachliche + Beratung. Ban Yaro haftet nicht für Schäden, die aus der Nutzung KI-generierter + Inhalte entstehen. +

`)} + + ${sec('12. Verfügbarkeit', ` +

+ Wir streben eine hohe Verfügbarkeit von Ban Yaro an und arbeiten kontinuierlich + daran, die App stabil zu halten. Eine Garantie für ununterbrochene Verfügbarkeit + können wir jedoch nicht übernehmen. Geplante Wartungsarbeiten werden nach + Möglichkeit vorab in der App angekündigt. +

`)} + + ${sec('13. Änderungen dieser AGB', ` +

+ Änderungen der AGB werden per E-Mail und in der App angekündigt — + mindestens 4 Wochen vor Inkrafttreten. Widersprichst du den Änderungen nicht + innerhalb dieser Frist, gelten sie als angenommen. Dein Widerspruchsrecht und + das Recht zur außerordentlichen Kündigung bleiben unberührt. +

`)} + + ${sec('14. Anwendbares Recht', ` +

+ Es gilt ausschließlich deutsches Recht. Als Verbraucher hast du + deinen allgemeinen Gerichtsstand. Die EU-Plattform zur Online-Streitbeilegung + (ec.europa.eu/consumers/odr) wurde eingestellt. Wir nehmen nicht an alternativen + Streitbeilegungsverfahren teil (§ 36 VSBG). +

`)} + + ${sec('15. Kontakt', ` +

+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app +

`)} + +

+ Stand: Mai 2026 · Version 2 +

+ +
+ `; + } + + function refresh() {} + + return { init, refresh }; +})(); diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 425536f..893883f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -32,6 +32,26 @@ window.Page_datenschutz = (() => { E-Mail: hallo@banyaro.app

`)} + ${sec('Hosting & Infrastruktur', ` +

+ Die App wird auf einem eigenen Server (Synology DiskStation) in Deutschland betrieben. + Alle Daten werden ausschließlich auf diesem Server gespeichert und nicht an externe + Hoster übermittelt. +

+

+ Für den E-Mail-Versand (Kontobestätigung, Benachrichtigungen, Rechnungen) nutzen wir + Brevo (Sendinblue SAS, 55 rue d'Amsterdam, 75008 Paris, Frankreich). + Brevo ist nach EU-Standardvertragsklauseln zertifiziert. Dabei werden E-Mail-Adresse + und Name übermittelt. Datenschutzinformationen: + brevo.com/de/legal/privacypolicy/. +

+

+ Für anonymisierte Nutzungsstatistiken betreiben wir Umami Analytics + auf unserem eigenen Server. Es werden keine personenbezogenen Daten oder IP-Adressen + gespeichert. Kein Tracking über Sitzungen hinweg. +

`)} + ${sec('Deine Daten gehören dir', `

Ban Yaro ist eine private Community-App. Dein Tagebuch, deine @@ -70,6 +90,9 @@ window.Page_datenschutz = (() => { Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft widerrufen werden (Art. 7 Abs. 3 DSGVO) — einfach die entsprechende Funktion in den Einstellungen deaktivieren oder die Browser-Freigabe entziehen. +

+

+ Impressum und rechtliche Grundlage nach § 5 DDG (Digitale-Dienste-Gesetz).

`)} ${sec('Datenweitergabe', ` @@ -92,6 +115,13 @@ window.Page_datenschutz = (() => { Du kannst Gespräche jederzeit selbst löschen.

`)} + ${sec('Moderation & Community', ` +

+ Zur Sicherstellung der Plattformqualität und Einhaltung unserer Nutzungsregeln können + Moderatoren und automatische Systeme Inhalte prüfen. Rechtsgrundlage ist + Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherer Plattform). +

`)} + ${sec('KI-Funktionen', `

Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge, @@ -127,6 +157,12 @@ window.Page_datenschutz = (() => { KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung. Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO) findet nicht statt. +

+

+ KI-Antworten können fehlerhaft oder unvollständig sein und dienen ausschließlich + allgemeinen Informationszwecken. Sie ersetzen keine tierärztliche oder fachliche + Beratung. Trotz EU-Standardvertragsklauseln besteht bei US-Anbietern ein Restrisiko, + dass US-Behörden auf übermittelte Daten zugreifen könnten.

`)} ${sec('Wetterdaten & Kartendienste', ` @@ -179,6 +215,16 @@ window.Page_datenschutz = (() => { style="${S.a}">openrouteservice.org/privacy-policy

`)} + ${sec('Technische Speicherung', ` +

+ Ban Yaro verwendet technisch notwendige Speichermechanismen für den Betrieb der App: + Session-Tokens und Authentifizierungsdaten werden im Local Storage des Browsers + gespeichert. Ein Service Worker speichert App-Inhalte lokal für die Offline-Nutzung + (Cache). Push-Benachrichtigungs-Token werden für die Zustellung von Hinweisen benötigt. + Diese Speicherung ist für die Kernfunktion der App erforderlich; eine Einwilligung ist + nach § 25 Abs. 2 TTDSG nicht erforderlich. Es werden keine Tracking-Cookies eingesetzt. +

`)} + ${sec('Push-Benachrichtigungen', `

Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den @@ -233,11 +279,28 @@ window.Page_datenschutz = (() => { Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde Beschwerde einzulegen:
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
- Promenade 27, 91522 Ansbach
+ Promenade 18, 91522 Ansbach
+ poststelle@lda.bayern.de · www.lda.bayern.de

`)} + ${sec('Zahlungsdaten', ` +

+ Wenn du ein kostenpflichtiges Abonnement abschließt, verarbeiten wir folgende Daten: + Name, E-Mail-Adresse, Rechnungsadresse und den Zahlungseingang. Rechtsgrundlage ist + Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Rechnungsdaten werden gemäß + § 147 AO 10 Jahre aufbewahrt. Rechnungen werden per E-Mail mit + TLS-Verschlüsselung zugestellt. +

+

+ Deine Zahlungsdaten (IBAN) werden nur für die Zuordnung des Zahlungseingangs intern + verwendet und nicht an Dritte weitergegeben. Die vertraglichen Bedingungen (Laufzeit, + Kündigung, Erstattung) findest du in unseren + AGB. +

`)} + ${sec('Speicherdauer', `

Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst — @@ -246,8 +309,14 @@ window.Page_datenschutz = (() => { Server-Logs werden nach 30 Tagen rotiert.

`)} + ${sec('Mindestalter', ` +

+ Die Nutzung von Ban Yaro ist nur Personen ab 18 Jahren gestattet. Durch die + Registrierung bestätigt der Nutzer, das 18. Lebensjahr vollendet zu haben. +

`)} +

- Stand: Mai 2026 · Version 2 + Stand: Mai 2026 · Version 3

diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index f04bf67..d15c9b5 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -6,6 +6,8 @@ window.Page_diary = (() => { + const _CACHE_KEY = 'by_diary_cache'; + // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -324,6 +326,7 @@ window.Page_diary = (() => { async function _load() { const dog = _appState.activeDog; if (!dog) return; + const cacheKey = _CACHE_KEY + '_' + dog.id; try { const params = { limit: LIMIT, offset: _offset }; if (_searchQuery) params.q = _searchQuery; @@ -331,6 +334,10 @@ window.Page_diary = (() => { const batch = await API.diary.list(dog.id, params); _entries = _entries.concat(batch); + if (_offset === 0 && !_searchQuery && !_filterMilestone) { + try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: batch })); } catch {} + } + // "Mehr laden" anzeigen wenn volle Page geladen wurde const loadMore = _container.querySelector('#diary-load-more'); if (loadMore) { @@ -339,7 +346,17 @@ window.Page_diary = (() => { // Stats-Bar befüllen _renderStatsBar(); - } catch (err) { + } catch { + try { + const raw = localStorage.getItem(cacheKey); + if (raw) { + const cached = JSON.parse(raw).data || []; + _entries = cached; + _renderStatsBar(); + UI.toast.info('Offline — zeige zuletzt geladene Einträge.'); + return; + } + } catch {} UI.toast.error('Einträge konnten nicht geladen werden.'); } } @@ -1748,6 +1765,7 @@ window.Page_diary = (() => { UI.toast.success('Eintrag gespeichert.'); } else { const created = await API.diary.create(_appState.activeDog.id, payload); + if (created?._queued) { UI.modal.close(); return; } if (_newFiles.length > 0) { const { uploaded, exifGps } = await _uploadNewFiles(created.id); created.media_items = uploaded; diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js index ffccb44..e05776d 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -24,12 +24,58 @@ window.Page_impressum = (() => {

Kontakt

-

+

E-Mail: hallo@banyaro.app
- Kontaktformular: Nachricht senden + Oder nutze das Formular — wir antworten in der Regel innerhalb von 24 Stunden.

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + +
@@ -46,9 +92,6 @@ window.Page_impressum = (() => {

Streitschlichtung

- Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: - https://ec.europa.eu/consumers/odr.
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).

@@ -61,20 +104,73 @@ window.Page_impressum = (() => { Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehmen wir keine Gewähr. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich. - Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir - keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer. + Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich + die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden + diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls + unverzüglich entfernt.

- Stand: April 2026 + Stand: Mai 2026

`; } + function _initContactForm(container) { + const form = container.querySelector('#contact-form'); + const statusEl = container.querySelector('#cf-status'); + const submitBtn = container.querySelector('#cf-submit'); + if (!form) return; + + form.addEventListener('submit', async e => { + e.preventDefault(); + const name = container.querySelector('#cf-name').value.trim(); + const email = container.querySelector('#cf-email').value.trim(); + const subject = container.querySelector('#cf-subject').value.trim(); + const message = container.querySelector('#cf-message').value.trim(); + + submitBtn.disabled = true; + submitBtn.textContent = 'Wird gesendet…'; + statusEl.style.display = 'none'; + + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, subject, message }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Fehler beim Senden.'); + } + statusEl.style.display = 'block'; + statusEl.style.background = 'var(--c-success-bg, #f0fdf4)'; + statusEl.style.color = 'var(--c-success, #16a34a)'; + statusEl.textContent = '✓ Nachricht gesendet — wir melden uns bald!'; + form.reset(); + } catch (err) { + statusEl.style.display = 'block'; + statusEl.style.background = '#fef2f2'; + statusEl.style.color = '#dc2626'; + statusEl.textContent = err.message || 'Fehler beim Senden. Bitte versuche es später erneut.'; + submitBtn.disabled = false; + submitBtn.textContent = 'Nachricht senden'; + } + }); + } + + const _origInit = init; + function refresh() {} - return { init, refresh }; + return { + init(container) { + _origInit(container); + _initContactForm(container); + }, + refresh + }; })(); diff --git a/backend/static/js/pages/laeufi.js b/backend/static/js/pages/laeufi.js index de09370..e0fdb68 100644 --- a/backend/static/js/pages/laeufi.js +++ b/backend/static/js/pages/laeufi.js @@ -48,20 +48,20 @@ window.Page_laeufi = (() => { `; return ` -
${logoHtml}

${UI.escape(zwinger)}

- Privater Bereich · Nur du siehst das + Privater Bereich · Nur du siehst das
`; @@ -69,14 +69,14 @@ window.Page_laeufi = (() => { function _render() { _container.innerHTML = ` -
+
${_privateHeader()} -
+

${UI.icon('thermometer')} Läufigkeit & Trächtigkeit

-
+

Lädt…

`; diff --git a/backend/static/js/pages/litters.js b/backend/static/js/pages/litters.js index 8f86a86..f6f1883 100644 --- a/backend/static/js/pages/litters.js +++ b/backend/static/js/pages/litters.js @@ -31,13 +31,13 @@ window.Page_litters = (() => { function _statusBadge(status) { const map = { - geplant: { label: 'Geplant', color: '#6B7280' }, - geboren: { label: 'Geboren', color: '#3B82F6' }, - verfuegbar: { label: 'Verfügbar', color: '#22C55E' }, - abgeschlossen: { label: 'Abgeschlossen', color: '#374151' }, + geplant: { label: 'Geplant', cls: 'badge-warning' }, + geboren: { label: 'Geboren', cls: 'badge-primary' }, + verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, + abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' }, }; - const s = map[status] || { label: status, color: '#6B7280' }; - return `${_esc(s.label)}`; + const s = map[status] || { label: status, cls: 'badge-muted' }; + return `${_esc(s.label)}`; } function _fmtDate(iso) { @@ -54,12 +54,12 @@ window.Page_litters = (() => { function _puppyStatusBadge(status) { const map = { - verfuegbar: { label: 'Verfügbar', color: '#22C55E' }, - reserviert: { label: 'Reserviert', color: '#F59E0B' }, - abgegeben: { label: 'Abgegeben', color: '#6B7280' }, + verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, + reserviert: { label: 'Reserviert', cls: 'badge-warning' }, + abgegeben: { label: 'Abgegeben', cls: 'badge-muted' }, }; - const s = map[status] || { label: status, color: '#9CA3AF' }; - return `${_esc(s.label)}`; + const s = map[status] || { label: status, cls: 'badge-muted' }; + return `${_esc(s.label)}`; } // ---------------------------------------------------------- @@ -113,20 +113,20 @@ window.Page_litters = (() => {
`; return ` -
${logoHtml}

${_esc(zwinger)}

- Privater Bereich · Nur du siehst das + Privater Bereich · Nur du siehst das
`; diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index 086224e..6f8fe0c 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -5,17 +5,72 @@ window.Page_lost = (() => { + // ---------------------------------------------------------- + // OFFLINE-CACHE + // ---------------------------------------------------------- + const _CACHE_KEY = 'by_lost_cache'; + const _PENDING_KEY = 'by_lost_pending'; + + function _getPending() { + try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } + } + function _setPending(list) { + try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} + } + function _addPending(data) { + const list = _getPending(); + const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, + created_at: new Date().toISOString() }; + list.push(entry); + _setPending(list); + return entry; + } + async function _syncPending() { + if (!navigator.onLine) return; + const list = _getPending(); + if (!list.length) return; + let ok = 0; + for (const item of [...list]) { + try { + const { id: _pid, _isPending, ...payload } = item; + await API.lost.report(payload); + _setPending(_getPending().filter(x => x.id !== item.id)); + ok++; + } catch {} + } + if (ok > 0) { UI.toast.success(`${ok} Meldung(en) synchronisiert.`); _loadReports(); } + } + window.addEventListener('online', _syncPending); + // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _map = null; - let _markers = []; - let _userMarker = null; - let _reports = []; - let _userPos = null; - let _leafletLoaded = false; + let _container = null; + let _appState = null; + let _map = null; + let _markers = []; + let _userMarker = null; + let _reports = []; + let _userPos = null; + let _leafletLoaded = false; + let _stylesInjected = false; + + function _injectStyles() { + if (_stylesInjected) return; + _stylesInjected = true; + const s = document.createElement('style'); + s.textContent = ` + @keyframes by-lost-pulse-r { + 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,.55), 0 2px 6px rgba(0,0,0,.3); } + 50% { box-shadow: 0 0 0 11px rgba(231,76,60,0), 0 2px 6px rgba(0,0,0,.3); } + } + @keyframes by-lost-pulse-p { + 0%,100% { box-shadow: 0 0 0 0 rgba(217,119,6,.55), 0 2px 6px rgba(0,0,0,.3); } + 50% { box-shadow: 0 0 0 11px rgba(217,119,6,0), 0 2px 6px rgba(0,0,0,.3); } + } + `; + document.head.appendChild(s); + } // ---------------------------------------------------------- // INIT @@ -113,6 +168,7 @@ window.Page_lost = (() => { // KARTE INITIALISIEREN // ---------------------------------------------------------- function _initMap() { + _injectStyles(); const mapEl = document.getElementById('lost-map'); if (!mapEl || !window.L || _map) return; @@ -180,7 +236,23 @@ window.Page_lost = (() => { } try { - _reports = await API.lost.list(_userPos.lat, _userPos.lon, 25); + const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25); + try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {} + + // Remove pending items already on the server (race: sync completed during fetch) + const rawPending = _getPending(); + const dedupedPending = rawPending.filter(p => + !fetched.some(f => f.name === p.name && + Math.abs(f.lat - p.lat) < 0.0001 && + Math.abs(f.lon - p.lon) < 0.0001) + ); + if (dedupedPending.length < rawPending.length) _setPending(dedupedPending); + + const pending = dedupedPending.map(p => ({ + ...p, + distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), + })); + _reports = [...pending, ...fetched]; _renderMarkers(); _renderHeld(); _renderList(); @@ -191,6 +263,31 @@ window.Page_lost = (() => { : 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾'; } } catch { + const offline_pending = _getPending().map(p => ({ + ...p, + distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), + })); + try { + const raw = localStorage.getItem(_CACHE_KEY); + if (raw) { + const cached = JSON.parse(raw).data || []; + _reports = [...offline_pending, ...cached]; + _renderMarkers(); + _renderHeld(); + _renderList(); + _updateBadge(_reports.length); + if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.'; + return; + } + } catch {} + _reports = offline_pending; + if (offline_pending.length) { + _renderMarkers(); + _renderHeld(); + _renderList(); + _updateBadge(_reports.length); + return; + } UI.toast.error('Meldungen konnten nicht geladen werden.'); } } @@ -204,20 +301,21 @@ window.Page_lost = (() => { _markers = []; _reports.forEach(r => { + const dotColor = r._isPending ? '#d97706' : '#e74c3c'; + const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r'; const icon = L.divIcon({ className : '', - html : `
🐕
`, + html : `
🐕
`, iconSize : [34, 34], iconAnchor : [17, 17], }); const distStr = r.distanz_m !== undefined - ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) + ? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; const marker = L.marker([r.lat, r.lon], { icon }) @@ -226,10 +324,11 @@ window.Page_lost = (() => { 🔍 ${_escape(r.name)}
${r.rasse ? _escape(r.rasse) + '
' : ''} ${distStr ? `📍 ${distStr} entfernt
` : ''} + ${r._isPending ? '⏳ Sync ausstehend
' : ''} 📅 ${_fmtDate(r.created_at)} `); - marker.on('click', () => _openDetail(r)); + if (!r._isPending) marker.on('click', () => _openDetail(r)); _markers.push(marker); }); } @@ -271,10 +370,19 @@ window.Page_lost = (() => { listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.querySelectorAll('[data-lost-id]').forEach(card => { card.addEventListener('click', () => { - const r = _reports.find(x => x.id === parseInt(card.dataset.lostId)); + const id = card.dataset.lostId; + const r = _reports.find(x => String(x.id) === id && !x._isPending); if (r) _openDetail(r); }); }); + listEl.querySelectorAll('.lost-discard-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const pid = btn.dataset.pendingId; + _setPending(_getPending().filter(x => x.id !== pid)); + _loadReports(); + }); + }); listEl.querySelectorAll('.lost-note-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); @@ -332,14 +440,24 @@ window.Page_lost = (() => { Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
- ${_appState.user ? `
- -
` : ''} + ${r._isPending + ? `
+ ⏳ Sync ausstehend + +
` + : (_appState.user ? `
+ +
` : '')}
@@ -350,6 +468,7 @@ window.Page_lost = (() => { // DETAIL-MODAL // ---------------------------------------------------------- function _openDetail(r) { + if (r._isPending) return; // Pending-Einträge haben keine Server-ID const isOwn = _appState.user && _appState.user.id === r.user_id; const isAdmin = _appState.user?.rolle === 'admin'; const distStr = r.distanz_m !== undefined @@ -632,7 +751,23 @@ window.Page_lost = (() => { client_time : API.clientNow(), }; - const created = await API.lost.report(payload); + let created; + try { + created = await API.lost.report(payload); + } catch (netErr) { + // Netzwerkfehler (TypeError = fetch failed) → offline speichern + if (netErr instanceof TypeError || !navigator.onLine) { + const pending = _addPending(payload); + pending.distanz_m = _userPos + ? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon)) + : 0; + UI.modal.close(); + UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); + _loadReports(); + return; + } + throw netErr; // API-Fehler (z.B. 422) → weitergeben + } // Foto hochladen if (photoInput?.files[0]) { diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index bc65d98..36dbc46 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -11,9 +11,11 @@ window.Page_map = (() => { let _map = null; let _leafletLoaded = false; let _userPos = null; - let _weatherLoaded = false; - let _placingMarker = false; - let _tempMarker = null; + let _weatherLoaded = false; + let _placingMarker = false; + let _tempMarker = null; + let _tileLayer = null; + let _themeObserver = null; // Standort-Tracking let _locationMarker = null; @@ -58,7 +60,8 @@ window.Page_map = (() => { zuechter: [], }; - const VISIBLE_KEY = 'by_map_visible_v1'; + const VISIBLE_KEY = 'by_map_visible_v1'; + const _MAP_POI_KEY = 'by_map_pois_cache'; let _visible = {}; // Gespeicherten Zustand laden, Fallback: alles sichtbar @@ -75,7 +78,7 @@ window.Page_map = (() => { // z: zIndexOffset — höher = weiter oben bei Überlappung const TYPEN = { - restaurant: { icon: '', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 }, + restaurant: { icon: '', label: 'Café & Restaurant', color: '#F97316', z: 10 }, freilauf: { icon: '', label: 'Freilauf', color: '#22C55E', z: 20 }, shop: { icon: '', label: 'Shop', color: '#3B82F6', z: 15 }, kotbeutel: { icon: '', label: 'Kotbeutel', color: '#84A98C', z: 5 }, @@ -92,7 +95,7 @@ window.Page_map = (() => { treffpunkt: { icon: '', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, community: { icon: '', label: 'Sonstiges', color: '#F59E0B', z: 30 }, zuechter: { icon: '', label: 'Züchter', color: '#7C3AED', z: 50 }, - hotel: { icon: '', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 }, + hotel: { icon: '', label: 'Hotel', color: '#0369a1', z: 20 }, }; // Frontend-Layer → Backend-Typ Mapping @@ -180,6 +183,7 @@ window.Page_map = (() => {
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
`, + border:2px solid rgba(52,68,36,0.65)">${n}`, iconSize: [36, 36], iconAnchor: [18, 18], }); }, @@ -612,14 +625,34 @@ window.Page_map = (() => { return _clusterGroups[layerKey]; } + function _isDarkMode() { + const t = document.documentElement.getAttribute('data-theme'); + if (t === 'dark') return true; + if (t === 'light') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + const _OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + const _DARK_FILTER = 'invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)'; + + function _buildTileLayer() { + return L.tileLayer(_OSM_URL, { maxZoom: 19 }); + } + + function _applyTileTheme() { + if (!_map) return; + const tilePaneEl = _map.getPane('tilePane'); + if (tilePaneEl) tilePaneEl.style.filter = _isDarkMode() ? _DARK_FILTER : ''; + } + function _updateZoomDisplay() { if (!_map) return; const z = Math.round(_map.getZoom()); const el = document.getElementById('map-zoom-info'); if (!el) return; - if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; } - else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; } - else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; } + if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; } + else if (z < 14) { el.textContent = `Z${z}`; el.title = 'Ab Z14: alle Layer'; el.style.opacity = '0.7'; } + else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; } } function _setOsmStatus(text, pct = null) { @@ -937,7 +970,7 @@ window.Page_map = (() => { width:32px;height:32px;border-radius:50%; display:flex;align-items:center;justify-content:center; box-shadow:0 2px 5px rgba(0,0,0,0.35); - border:2px solid rgba(255,255,255,0.7)">${t.icon}`, + border:2px solid rgba(52,68,36,0.55)">${t.icon}`, iconSize: [32, 32], iconAnchor: [16, 16], }); @@ -1217,9 +1250,41 @@ window.Page_map = (() => { API.breeder.mapMarkers(), ]); - if (places.status === 'fulfilled') _addPlaces(places.value); - if (poisonList.status === 'fulfilled') _addPoison(poisonList.value); - if (breederList.status === 'fulfilled') _addBreeders(breederList.value); + const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected'); + if (allFailed) { + try { + const raw = localStorage.getItem(_MAP_POI_KEY); + if (raw) { + const cached = JSON.parse(raw); + _addPlaces(cached.places || []); + _addPoison(cached.poison || []); + _addBreeders(cached.breeders || []); + UI.toast.info('Offline — Karte zeigt gecachte Kacheln. POI-Daten eventuell veraltet.'); + _scheduleOsmLoad(); + return; + } + } catch {} + } + + const placesVal = places.status === 'fulfilled' ? places.value : []; + const poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : []; + const breederVal = breederList.status === 'fulfilled' ? breederList.value : []; + + if (places.status === 'fulfilled') _addPlaces(placesVal); + if (poisonList.status === 'fulfilled') _addPoison(poisonVal); + if (breederList.status === 'fulfilled') _addBreeders(breederVal); + + if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') { + try { + localStorage.setItem(_MAP_POI_KEY, JSON.stringify({ + ts: Date.now(), + places: placesVal, + poison: poisonVal, + breeders: breederVal, + })); + } catch {} + } + _scheduleOsmLoad(); } @@ -1270,7 +1335,7 @@ window.Page_map = (() => { width:32px;height:32px;border-radius:50%; display:flex;align-items:center;justify-content:center; box-shadow:0 2px 5px rgba(0,0,0,0.35); - border:2px solid rgba(255,255,255,0.7)">${t.icon}`, + border:2px solid rgba(52,68,36,0.55)">${t.icon}`, iconSize: [32, 32], iconAnchor: [16, 16], }); @@ -1314,7 +1379,7 @@ window.Page_map = (() => { width:32px;height:32px;border-radius:50%; display:flex;align-items:center;justify-content:center; box-shadow:0 2px 5px rgba(0,0,0,0.35); - border:2px solid rgba(255,255,255,0.7)">${t.icon}`, + border:2px solid rgba(52,68,36,0.55)">${t.icon}`, iconSize: [32, 32], iconAnchor: [16, 16], }); return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 }) diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index 68e7c50..150fddc 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -5,6 +5,8 @@ window.Page_poison = (() => { + const _CACHE_KEY = 'by_poison_cache'; + // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -171,6 +173,7 @@ window.Page_poison = (() => { try { _reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000); + try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {} _renderMarkers(); _renderList(); _updateBadge(_reports.length); @@ -180,6 +183,18 @@ window.Page_poison = (() => { : 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).'; } } catch { + try { + const raw = localStorage.getItem(_CACHE_KEY); + if (raw) { + _reports = JSON.parse(raw).data || []; + _renderMarkers(); + _renderList(); + _updateBadge(_reports.length); + if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`; + UI.toast.info('Offline — zeige zuletzt geladene Daten.'); + return; + } + } catch {} UI.toast.error('Meldungen konnten nicht geladen werden.'); } } @@ -528,6 +543,12 @@ window.Page_poison = (() => { const created = await API.poison.report(payload); + // SW hat Request in Queue gelegt (offline) + if (created?._queued) { + _showPoisonThanks(true); + return; + } + // Foto hochladen if (photoInput?.files[0]) { try { @@ -540,8 +561,7 @@ window.Page_poison = (() => { } } - // Distanz client-seitig berechnen (für sofortige Anzeige) - // _userPos aktualisieren falls Picker neuen Standort geliefert hat + // Distanz client-seitig berechnen if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon }; created.distanz_m = _userPos ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) @@ -553,12 +573,45 @@ window.Page_poison = (() => { _updateBadge(_reports.length); App.checkNearbyAlerts(); App.callModule('map', 'refresh'); - UI.toast.success('Giftköder gemeldet! Danke für die Warnung.'); - UI.modal.close(); + _showPoisonThanks(false); }); }); } + // ---------------------------------------------------------- + // DANKE-OVERLAY nach Giftköder-Meldung + // ---------------------------------------------------------- + function _showPoisonThanks(isQueued) { + const offlineNote = isQueued + ? `

+ + Wird synchronisiert sobald du wieder online bist. +

` + : ''; + UI.modal.open({ + title: 'Danke für deine Meldung!', + body: ` +
+
+ +
+

+ Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung. +

+

+ + Vielen Dank, dass du die Community schützt! +

+ ${offlineNote} +
+ `, + footer: ``, + }); + document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close); + setTimeout(() => UI.modal.close(), 5000); + } + // ---------------------------------------------------------- // BADGE (Sidebar + Bottom-Nav) // ---------------------------------------------------------- diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 80cffa9..b09e4fb 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -5,6 +5,40 @@ window.Page_routes = (() => { + const _CACHE_KEY = 'by_routes_cache'; + const _PENDING_KEY = 'by_routes_pending'; + + function _getPending() { + try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } + } + function _setPending(list) { + try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} + } + function _addPending(data) { + const list = _getPending(); + const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, + created_at: new Date().toISOString(), user_id: null }; + list.push(entry); + _setPending(list); + return entry; + } + async function _syncPending() { + if (!navigator.onLine) return; + const list = _getPending(); + if (!list.length) return; + let ok = 0; + for (const r of [...list]) { + try { + const { id: _pid, _isPending, ...payload } = r; + await API.routes.create(payload); + _setPending(_getPending().filter(x => x.id !== r.id)); + ok++; + } catch {} + } + if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); } + } + window.addEventListener('online', _syncPending); + let _container = null; let _appState = null; let _data = []; @@ -634,7 +668,8 @@ window.Page_routes = (() => { if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (_recOvl) return; - await UI.loadLeaflet?.() ?? Promise.resolve(); + try { await (UI.loadLeaflet?.() ?? Promise.resolve()); } + catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); } const ovl = document.createElement('div'); ovl.id = 'rk-rec-ovl'; @@ -691,24 +726,37 @@ window.Page_routes = (() => { document.body.appendChild(ovl); _recOvl = ovl; - const pos = _userPos || { lat: 48.1, lon: 11.5 }; - _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) - .setView([pos.lat, pos.lon], 15); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); - _recLocMarker = L.circleMarker([pos.lat, pos.lon], { - radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 - }).addTo(_recMap); + // Listener sofort nach DOM-Einfügen — nicht nach async-Operationen + ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); + ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); - // Get accurate position + // Map-Setup: Leaflet könnte offline fehlen → alles in try/catch + const pos = _userPos || { lat: 48.1, lon: 11.5 }; + try { + if (!window.L) throw new Error('Leaflet not loaded'); + _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) + .setView([pos.lat, pos.lon], 15); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); + _recLocMarker = L.circleMarker([pos.lat, pos.lon], { + radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 + }).addTo(_recMap); + } catch { + const mapWrap = ovl.querySelector('#rk-rec-map-wrap'); + if (mapWrap) mapWrap.innerHTML = + `
+ 📡 + Karte offline nicht verfügbar — GPS läuft trotzdem +
`; + } + + // Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS) try { const p = await API.getLocation(); _userPos = p; - _recMap.setView([p.lat, p.lon], 16); - _recLocMarker.setLatLng([p.lat, p.lon]); + _recMap?.setView([p.lat, p.lon], 16); + _recLocMarker?.setLatLng([p.lat, p.lon]); } catch {} - - ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); - ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); } async function _startRecInOvl() { @@ -732,6 +780,7 @@ window.Page_routes = (() => { setTimeout(() => banner.remove(), 9000); } + const ctrl = document.getElementById('rk-rec-ctrl'); ctrl.innerHTML = ` ` }); + const agbBox = document.getElementById('agb-checkbox'); + const widerrufBox = document.getElementById('widerruf-checkbox'); + const sendBtn = document.getElementById('upgrade-request-send-btn'); + if (sendBtn) sendBtn.disabled = true; + + const _checkBtns = () => { + if (sendBtn) sendBtn.disabled = !(agbBox?.checked && widerrufBox?.checked); + }; + agbBox?.addEventListener('change', _checkBtns); + widerrufBox?.addEventListener('change', _checkBtns); + document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => { const btn = document.getElementById('upgrade-request-send-btn'); if (!btn) return; @@ -357,7 +398,8 @@ window.Page_settings = (() => { } try { - const res = await API.auth.upgradeRequest(tier); + const widerrufAt = new Date().toLocaleString('de-DE'); + const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`); UI.modal.close(); if (res.already) { UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.'); @@ -1135,6 +1177,22 @@ window.Page_settings = (() => { value="${_esc(u.social_link || '')}" style="${inputStyle}"> +
+ +
Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.
+ +
+
+ + +
Wird nur für Geburtstagsgrüße in der App verwendet.
+
@@ -1161,8 +1219,11 @@ window.Page_settings = (() => { erfahrung: fd.erfahrung || '', social_link: fd.social_link || '', profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public', + billing_address: fd.billing_address || '', + geburtstag: fd.geburtstag || '', }); Object.assign(_appState.user, updated); + window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting) UI.modal.close?.(); UI.toast.success('Profil gespeichert.'); _render(); diff --git a/backend/static/js/pages/social.js b/backend/static/js/pages/social.js index 010d493..d1af781 100644 --- a/backend/static/js/pages/social.js +++ b/backend/static/js/pages/social.js @@ -43,6 +43,7 @@ window.Page_social = (() => { function _render() { const lvlBar = _stats ? _levelBar(_stats) : ''; _el.innerHTML = ` +
@@ -71,7 +72,8 @@ window.Page_social = (() => { color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}"> ${l}`).join('')}
-
`; +
+
`; _el.querySelectorAll('.sm-tab').forEach(b => b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); })); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index c6ba308..9410e0a 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -56,6 +56,7 @@ window.Page_uebungen = (() => { { id: 'welpe-basics', label: 'Welpe Basics' }, { id: 'grundlagen', label: 'Trainingsgrundlagen' }, { id: 'ki-trainer', label: 'KI-Trainer' }, + { id: 'verlauf', label: 'Protokoll' }, ]; // ---------------------------------------------------------- @@ -541,11 +542,15 @@ window.Page_uebungen = (() => { _renderContent(); } function onDogChange() { - _statsData = null; - _badgesData = null; - _progressCache = {}; + _statsData = null; + _badgesData = null; + _progressCache = {}; _progressLoaded = false; - _exerciseStats = {}; + _exerciseStats = {}; + _verlaufSessions = []; + _verlaufOffset = 0; + _verlaufLoading = false; + _verlaufView = 'datum'; _render(); _loadStatsAndBadges(); _loadVirtualTrainer(); @@ -558,34 +563,21 @@ window.Page_uebungen = (() => { _container.innerHTML = `
${UI.dogChip(_appState)}
-
- - - - - -
- - - -
+
+ +
${_renderTabs()}
@@ -980,10 +972,11 @@ window.Page_uebungen = (() => { const isExerciseTab = ['grundkommandos','tricks','problemverhalten', 'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab); + const isVerlauf = _activeTab === 'verlauf'; const showIf = v => v ? '' : 'none'; const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement; - if (quickWrap) quickWrap.style.display = showIf(isExerciseTab); + if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none'; const trainerEl = _container.querySelector('#ueb-trainer'); const suggestEl = _container.querySelector('#ueb-suggestions'); const bannerEl = _container.querySelector('#ueb-stats-banner'); @@ -1011,6 +1004,17 @@ window.Page_uebungen = (() => { break; } case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; + case 'verlauf': { + if (_verlaufSessions.length > 0) { + el.innerHTML = `
${_verlaufToggleHtml()}
`; + _renderVerlaufList(el.querySelector('#verlauf-list')); + } else { + el.innerHTML = _renderVerlaufShell(); + _loadVerlauf(); + } + _bindVerlaufToggle(); + break; + } case 'ki-trainer': if (!App.hasPro(_appState?.user)) { el.innerHTML = `
@@ -1647,18 +1651,6 @@ window.Page_uebungen = (() => { background:var(--c-surface);color:var(--c-text);line-height:1.5">
- - - @@ -1714,7 +1706,6 @@ window.Page_uebungen = (() => { btn.style.background = 'var(--c-primary-subtle)'; btn.style.borderColor = 'var(--c-primary)'; btn.style.transform = 'scale(1.15)'; - _checkMilestoneVisibility(); }); }); @@ -1738,17 +1729,9 @@ window.Page_uebungen = (() => { overlay.querySelectorAll('.ueb-stern-btn').forEach(b => { b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35'; }); - _checkMilestoneVisibility(); }); }); - function _checkMilestoneVisibility() { - const wrap = overlay.querySelector('#ueb-log-milestone-wrap'); - if (!wrap) return; - const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4; - wrap.hidden = !show; - } - // Save overlay.querySelector('#ueb-log-save').addEventListener('click', async () => { const dogId = _dogId(); @@ -1761,20 +1744,17 @@ window.Page_uebungen = (() => { const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`; const today = new Date().toISOString().slice(0, 10); - const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden && - overlay.querySelector('#ueb-log-milestone').checked; const body = { - dog_id: dogId, - exercise_id: exerciseId, - exercise_name: exerciseName, - datum: today, - wiederholungen: wiederholungen, - erfolgsquote: erfolgsquote, - hund_stimmung: stimmung || null, - zufriedenheit: zufriedenheit || null, - notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null, - tagebuch_eintrag: tagebuch, + dog_id: dogId, + exercise_id: exerciseId, + exercise_name: exerciseName, + datum: today, + wiederholungen: wiederholungen, + erfolgsquote: erfolgsquote, + hund_stimmung: stimmung || null, + zufriedenheit: zufriedenheit || null, + notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null, }; try { @@ -1806,12 +1786,6 @@ window.Page_uebungen = (() => { }); } - if (resp.diary_entry_id) { - setTimeout(() => { - UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.'); - }, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000); - } - // Stats-Banner + Trainer aktualisieren _statsData = null; _loadStatsAndBadges(); @@ -1995,6 +1969,325 @@ window.Page_uebungen = (() => { }); } + // ---------------------------------------------------------- + // TRAININGSPROTOKOLL (Verlauf-Tab) + // ---------------------------------------------------------- + let _verlaufSessions = []; + let _verlaufOffset = 0; + let _verlaufHasMore = false; + let _verlaufLoading = false; + let _verlaufView = 'datum'; // 'datum' | 'uebung' + const _VERLAUF_LIMIT = 30; + + const _ERFOLG_EMOJI = { 0: '😓', 25: '😐', 50: '🙂', 75: '😊', 100: '🎉' }; + const _STIMMUNG_EMOJI = { aufmerksam: '🎯', müde: '😴', abgelenkt: '🌪️', super: '⚡' }; + + function _renderVerlaufShell() { + const dogId = _dogId(); + if (!dogId) { + return `
+

Wähle einen Hund aus um das Protokoll zu sehen.

+
`; + } + return `
+ ${_verlaufToggleHtml()} +
+
+ +
+
+
`; + } + + function _verlaufToggleHtml() { + const btnBase = `padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); + font-size:var(--text-xs);font-weight:var(--weight-semibold);cursor:pointer; + border:1px solid var(--c-border);transition:all .15s`; + const active = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`; + const inactive = `background:var(--c-surface-2);color:var(--c-text-secondary)`; + return ` +
+ + +
`; + } + + async function _loadVerlauf(append = false) { + if (_verlaufLoading) return; + const dogId = _dogId(); + if (!dogId) return; + + if (!append) { + _verlaufSessions = []; + _verlaufOffset = 0; + } + + _verlaufLoading = true; + const data = await _apiGet( + `/api/training/sessions?dog_id=${dogId}&limit=${_VERLAUF_LIMIT + 1}&offset=${_verlaufOffset}` + ).catch(() => null); + _verlaufLoading = false; + + // Element nach await neu holen — könnte durch Re-Render veraltet sein + const el = _container?.querySelector('#verlauf-list'); + if (!el) return; + + if (!data) { + if (!append) el.innerHTML = `
Fehler beim Laden.
`; + return; + } + + _verlaufHasMore = data.length > _VERLAUF_LIMIT; + const rows = data.slice(0, _VERLAUF_LIMIT); + _verlaufSessions = append ? [..._verlaufSessions, ...rows] : rows; + _verlaufOffset += rows.length; + + _renderVerlaufList(el); + } + + function _bindVerlaufToggle() { + const wrap = _container?.querySelector('#verlauf-wrap'); + if (!wrap) return; + const btnDatum = wrap.querySelector('#verlauf-btn-datum'); + const btnUebung = wrap.querySelector('#verlauf-btn-uebung'); + const setActive = view => { + _verlaufView = view; + const active = `var(--c-primary)`; + const inBg = `var(--c-surface-2)`; + btnDatum.style.background = view === 'datum' ? active : inBg; + btnDatum.style.color = view === 'datum' ? '#fff' : 'var(--c-text-secondary)'; + btnDatum.style.borderColor = view === 'datum' ? active : 'var(--c-border)'; + btnUebung.style.background = view === 'uebung' ? active : inBg; + btnUebung.style.color = view === 'uebung' ? '#fff' : 'var(--c-text-secondary)'; + btnUebung.style.borderColor = view === 'uebung' ? active : 'var(--c-border)'; + const listEl = wrap.querySelector('#verlauf-list'); + if (listEl) _renderVerlaufList(listEl); + }; + btnDatum?.addEventListener('click', () => setActive('datum')); + btnUebung?.addEventListener('click', () => setActive('uebung')); + } + + function _renderVerlaufList(el) { + if (!_verlaufSessions.length) { + el.innerHTML = ` +
+ +

Noch keine Trainingseinheiten geloggt.

+

+ Tippe in einer Übung auf "+ Einheit" um zu starten. +

+
`; + return; + } + if (_verlaufView === 'uebung') { + _renderVerlaufByUebung(el); + } else { + _renderVerlaufByDatum(el); + } + } + + function _sessionRow(s) { + const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂'; + const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : ''; + const topBadge = s.ist_top + ? `TOP` + : ''; + const noteHtml = s.notiz + ? `
${_esc(s.notiz)}
` + : ''; + return ` +
+ ${erfolg} +
+
+ ${_esc(s.exercise_name)} + ${topBadge} +
+
+ ${s.wiederholungen}× Wdh.${stimmung ? ' · ' + stimmung : ''}${s.zufriedenheit ? ' · ' + '⭐'.repeat(s.zufriedenheit) : ''} +
+ ${noteHtml} +
+
`; + } + + function _renderVerlaufByDatum(el) { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + const groups = {}; + _verlaufSessions.forEach(s => { + groups[s.datum] = groups[s.datum] || []; + groups[s.datum].push(s); + }); + + const html = Object.entries(groups).map(([datum, sessions]) => { + let label; + if (datum === today) label = 'Heute'; + else if (datum === yesterday) label = 'Gestern'; + else { + const d = new Date(datum + 'T00:00:00'); + label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' }); + } + return ` +
+
+ ${_esc(label)} +
+
+ ${sessions.map(_sessionRow).join('')} +
+
`; + }).join(''); + + const moreBtn = _verlaufHasMore + ? `` + : ''; + + el.innerHTML = html + moreBtn; + el.querySelector('#verlauf-more')?.addEventListener('click', () => _loadVerlauf(true)); + } + + function _renderVerlaufByUebung(el) { + // Sessions nach Übungsname gruppieren + const groups = {}; + _verlaufSessions.forEach(s => { + if (!groups[s.exercise_name]) groups[s.exercise_name] = []; + groups[s.exercise_name].push(s); + }); + + // Pro Gruppe Stats berechnen + const today = new Date().toISOString().slice(0, 10); + const exerciseStats = Object.entries(groups).map(([name, sessions]) => { + const avg = Math.round(sessions.reduce((a, s) => a + s.erfolgsquote, 0) / sessions.length); + const recent = sessions.slice(0, 3); + const older = sessions.slice(3, 6); + let trend = 'new'; + if (older.length) { + const rAvg = recent.reduce((a, s) => a + s.erfolgsquote, 0) / recent.length; + const oAvg = older.reduce((a, s) => a + s.erfolgsquote, 0) / older.length; + trend = rAvg - oAvg > 10 ? 'up' : rAvg - oAvg < -10 ? 'down' : 'stable'; + } + const lastDate = sessions[0].datum; + const daysSince = Math.floor((new Date(today) - new Date(lastDate)) / 86400000); + return { name, sessions, avg, trend, lastDate, daysSince, topCount: sessions.filter(s => s.ist_top).length }; + }); + + // Sortieren: zuletzt trainiert zuerst + exerciseStats.sort((a, b) => a.daysSince - b.daysSince); + + const TREND_ICON = { up: '↑', down: '↓', stable: '→', new: '★' }; + const TREND_COLOR = { up: '#15803d', down: '#dc2626', stable: 'var(--c-text-secondary)', new: 'var(--c-primary)' }; + + const cards = exerciseStats.map((ex, i) => { + const uid = `vl-ex-${i}`; + const barColor = ex.avg >= 75 ? '#15803d' : ex.avg >= 50 ? '#c2410c' : '#dc2626'; + const barBg = ex.avg >= 75 ? 'rgba(22,163,74,0.15)' : ex.avg >= 50 ? 'rgba(194,65,12,0.15)' : 'rgba(220,38,38,0.15)'; + const lastLabel = ex.daysSince === 0 ? 'Heute' + : ex.daysSince === 1 ? 'Gestern' + : `vor ${ex.daysSince} Tagen`; + const sessionRows = ex.sessions.map(s => { + const d = new Date(s.datum + 'T00:00:00'); + const dateLabel = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); + const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂'; + const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : ''; + const top = s.ist_top ? ' ★' : ''; + return ` +
+ ${_esc(dateLabel)} + ${erfolg} + ${s.erfolgsquote}%${top} + ${s.wiederholungen}× Wdh.${stimmung ? ' ' + stimmung : ''} +
`; + }).join(''); + + return ` +
+ + + + +
`; + }).join(''); + + const hint = _verlaufHasMore + ? `
+ Zeigt die letzten ${_verlaufSessions.length} Einheiten — ältere nicht berücksichtigt. +
` + : ''; + + el.innerHTML = cards + hint; + + // Akkordeon-Binding + el.querySelectorAll('.verlauf-ex-btn').forEach(btn => { + btn.addEventListener('click', () => { + const uid = btn.dataset.uid; + const body = document.getElementById(uid); + const chev = el.querySelector(`.verlauf-ex-chevron[data-uid="${uid}"]`); + const isOpen = !body.hidden; + body.hidden = isOpen; + if (chev) chev.style.transform = isOpen ? '' : 'rotate(180deg)'; + }); + }); + } + // ---------------------------------------------------------- // TRAININGSGRUNDLAGEN // ---------------------------------------------------------- diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index d925a29..e0c6c40 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -5,6 +5,45 @@ window.Page_walks = (() => { + // ---------------------------------------------------------- + // OFFLINE-CACHE + // ---------------------------------------------------------- + const _CACHE_KEY = 'by_walks_cache'; + const _PENDING_KEY = 'by_walks_pending'; + + function _getPending() { + try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } + } + function _setPending(list) { + try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} + } + function _addPending(data) { + const list = _getPending(); + const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, + created_at: new Date().toISOString(), + teilnehmer_count: 1, max_teilnehmer: data.max_teilnehmer || 10, + status: 'open' }; + list.push(entry); + _setPending(list); + return entry; + } + async function _syncPending() { + if (!navigator.onLine) return; + const list = _getPending(); + if (!list.length) return; + let ok = 0; + for (const item of [...list]) { + try { + const { id: _pid, _isPending, created_at: _ca, teilnehmer_count: _tc, status: _st, ...payload } = item; + await API.walks.create(payload); + _setPending(_getPending().filter(x => x.id !== item.id)); + ok++; + } catch {} + } + if (ok > 0) { UI.toast.success(`${ok} Treffen synchronisiert.`); _loadData(); } + } + window.addEventListener('online', _syncPending); + let _container = null; let _appState = null; let _data = []; @@ -195,14 +234,16 @@ window.Page_walks = (() => { // Daten laden // ---------------------------------------------------------- async function _loadData() { + const pending = _getPending(); try { - _data = await API.walks.list( + const fetched = await API.walks.list( _userPos?.lat ?? null, _userPos?.lon ?? null ); + try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {} + _data = [...pending, ...fetched]; _renderList(); _renderMarkers(); - // Desktop: Karte direkt initialisieren (beide Panels sichtbar) if (window.innerWidth >= 1024) { UI.loadLeaflet().then(() => { _initMap(); @@ -210,8 +251,20 @@ window.Page_walks = (() => { setTimeout(() => _map?.invalidateSize(), 400); }); } - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Laden.'); + } catch { + try { + const raw = localStorage.getItem(_CACHE_KEY); + if (raw) { + _data = [...pending, ...(JSON.parse(raw).data || [])]; + _renderList(); + _renderMarkers(); + UI.toast.info('Offline — zeige zuletzt geladene Treffen.'); + return; + } + } catch {} + _data = pending; + if (pending.length) { _renderList(); _renderMarkers(); return; } + UI.toast.error('Treffen konnten nicht geladen werden.'); } } @@ -291,6 +344,7 @@ window.Page_walks = (() => { ${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer} ${isOwn ? 'Mein Treffen' : ''} + ${w._isPending ? `⏳ Sync ausstehend` : ''}
@@ -1128,15 +1182,30 @@ window.Page_walks = (() => { const idx = _data.findIndex(w => w.id === walk.id); if (idx !== -1) _data[idx] = { ..._data[idx], ...updated }; UI.toast.success('Treffen aktualisiert.'); + UI.modal.close(); + _renderList(); + _renderMarkers(); } else { - const created = await API.walks.create(payload); + let created; + try { + created = await API.walks.create(payload); + } catch (netErr) { + if (netErr instanceof TypeError || !navigator.onLine) { + _addPending(payload); + UI.modal.close(); + UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); + _loadData(); + return; + } + throw netErr; + } + if (created?._queued) { UI.modal.close(); _loadData(); return; } _data.unshift({ ...created, teilnehmer_count: 0 }); UI.toast.success('Treffen geplant! 🎉'); + UI.modal.close(); + _renderList(); + _renderMarkers(); } - - UI.modal.close(); - _renderList(); - _renderMarkers(); }); }); } diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index 9e60fb0..256f499 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -167,6 +167,8 @@ window.Page_welcome = (() => { Impressum  ·  Datenschutz +  ·  + AGB

`; diff --git a/backend/static/js/pages/zuchthunde.js b/backend/static/js/pages/zuchthunde.js index 0d4d96f..9798b02 100644 --- a/backend/static/js/pages/zuchthunde.js +++ b/backend/static/js/pages/zuchthunde.js @@ -115,20 +115,20 @@ window.Page_zuchthunde = (() => {
`; return ` -
${logoHtml}

${_esc(zwinger)}

- Privater Bereich · Nur du siehst das + Privater Bereich · Nur du siehst das
`; diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index a0b69b1..9da043f 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -83,13 +83,19 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint + // Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen let _vvCleanup = null; const vv = window.visualViewport; + const modal = overlay.querySelector('.modal'); if (vv) { const adjust = () => { - const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); - overlay.style.paddingBottom = (kb + 16) + 'px'; + const visible = vv.height; + const offset = vv.offsetTop; + const kb = Math.max(0, window.innerHeight - visible - offset); + // Overlay-Padding damit Modal nach oben rückt + overlay.style.paddingBottom = (kb + 8) + 'px'; + // Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt + if (modal) modal.style.maxHeight = (visible - 24) + 'px'; }; vv.addEventListener('resize', adjust); vv.addEventListener('scroll', adjust); @@ -97,19 +103,37 @@ const UI = (() => { vv.removeEventListener('resize', adjust); vv.removeEventListener('scroll', adjust); overlay.style.paddingBottom = ''; + if (modal) modal.style.maxHeight = ''; }; } - _current = { overlay, onClose, _vvCleanup }; + // Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView + // arbeitet nicht zuverlässig in overflow-Containern) + const _onFocusin = e => { + const el = e.target; + if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return; + setTimeout(() => { + const body = el.closest('.modal-body'); + if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; } + const elBottom = el.getBoundingClientRect().bottom; + const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight; + const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur + if (gap > 0) body.scrollTop += gap; + }, 380); + }; + overlay.addEventListener('focusin', _onFocusin); + + _current = { overlay, onClose, _vvCleanup, _onFocusin }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; - const { onClose, _vvCleanup } = _current; + const { onClose, _vvCleanup, _onFocusin } = _current; onClose?.(); _vvCleanup?.(); + if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin); _current.overlay.remove(); document.documentElement.classList.remove('modal-open'); _current = null; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index b250a29..7459eeb 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -598,13 +598,21 @@ window.Worlds = (() => { let _cfgCache = null; function _mergeDefaults(cfg) { - // Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen - const result = JSON.parse(JSON.stringify(cfg)); + const result = JSON.parse(JSON.stringify(cfg)); + const hidden = new Set(result.hidden || []); + // Chips die bereits einer Welt zugewiesen sind, nicht nochmal einfügen + const allAssigned = new Set([ + ...(result.jetzt || []), ...(result.hund || []), ...(result.welt || []), + ]); for (const world of ['jetzt', 'hund', 'welt']) { const def = _DEFAULT_CONFIG[world] || []; const saved = result[world] || []; for (const page of def) { - if (!saved.includes(page)) saved.push(page); + // Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet + if (!allAssigned.has(page) && !hidden.has(page)) { + saved.push(page); + allAssigned.add(page); + } } result[world] = saved; } @@ -637,6 +645,11 @@ window.Worlds = (() => { } function _saveConfig(cfg) { + // Bewusst ausgeblendete Chips tracken: Default-Chips die keiner Welt zugewiesen sind + const allAssigned = new Set([...(cfg.jetzt||[]), ...(cfg.hund||[]), ...(cfg.welt||[])]); + const allDefault = [..._DEFAULT_CONFIG.jetzt, ..._DEFAULT_CONFIG.hund, ..._DEFAULT_CONFIG.welt]; + cfg.hidden = allDefault.filter(p => !allAssigned.has(p)); + _cfgCache = cfg; try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} if (_state?.user) { @@ -710,15 +723,18 @@ window.Worlds = (() => { const bottomNav = document.getElementById('bottom-nav'); if (bottomNav) bottomNav.style.display = 'none'; + const _isDesktop = window.innerWidth >= 768; const ov = document.createElement('div'); ov.id = 'wc-overlay'; - ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end'; + ov.style.cssText = _isDesktop + ? 'position:fixed;inset:0;z-index:500;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)' + : 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end'; document.body.appendChild(ov); const _removeDragListeners = () => { - document.removeEventListener('touchmove', _onDragMove); - document.removeEventListener('touchend', _onDragEnd); - document.removeEventListener('touchcancel', _onDragEnd); + document.removeEventListener('pointermove', _onDragMove); + document.removeEventListener('pointerup', _onDragEnd); + document.removeEventListener('pointercancel', _onDragEnd); }; const _cancelDrag = () => { if (!_drag) return; @@ -736,11 +752,14 @@ window.Worlds = (() => { }; function _render() { + const _sheetStyle = _isDesktop + ? 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:20px;width:90%;max-width:1100px;max-height:88vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 20px' + : 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)'; + const _gridCols = _isDesktop ? 'repeat(auto-fill,minmax(120px,1fr))' : 'repeat(4,1fr)'; + const _chipH = _isDesktop ? '64px' : '80px'; ov.innerHTML = ` -
-
+ ${!_isDesktop ? '
' : ''} +
(${chips.length})` : ''}
${chips.map(c => `
${!c.pinned ? ` @@ -855,29 +874,27 @@ window.Worlds = (() => { }); }); - // Touch-Drag + // Pointer-Drag (funktioniert auf Mouse + Touch) ov.querySelectorAll('.wc-chip').forEach(chip => { - chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true }); + chip.addEventListener('pointerdown', e => _onDragStart(e, chip)); }); - document.addEventListener('touchmove', _onDragMove, { passive: false }); - document.addEventListener('touchend', _onDragEnd); } function _onDragStart(e, chipEl) { + if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste if (_drag) _cancelDrag(); - const touch = e.touches[0]; - // Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte) + chipEl.setPointerCapture(e.pointerId); _drag = { page: chipEl.dataset.page, zone: chipEl.dataset.zone, chipEl, ghost: null, dropZone: null, active: false, - startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0, + startX: e.clientX, startY: e.clientY, ox: 0, oy: 0, }; - document.addEventListener('touchmove', _onDragMove, { passive: false }); - document.addEventListener('touchend', _onDragEnd); - document.addEventListener('touchcancel', _onDragEnd); + document.addEventListener('pointermove', _onDragMove); + document.addEventListener('pointerup', _onDragEnd); + document.addEventListener('pointercancel', _onDragEnd); } - function _activateDrag(touch) { + function _activateDrag(e) { const rect = _drag.chipEl.getBoundingClientRect(); _drag.ox = _drag.startX - rect.left; _drag.oy = _drag.startY - rect.top; @@ -891,8 +908,8 @@ window.Worlds = (() => { ghost.style.transform = 'scale(1.08) rotate(-2deg)'; ghost.style.width = rect.width + 'px'; ghost.style.height = rect.height + 'px'; - ghost.style.left = (touch.clientX - _drag.ox) + 'px'; - ghost.style.top = (touch.clientY - _drag.oy) + 'px'; + ghost.style.left = (e.clientX - _drag.ox) + 'px'; + ghost.style.top = (e.clientY - _drag.oy) + 'px'; ghost.style.transition = 'none'; ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; document.body.appendChild(ghost); @@ -902,24 +919,22 @@ window.Worlds = (() => { function _onDragMove(e) { if (!_drag) return; - const touch = e.touches[0]; if (!_drag.active) { - const dx = Math.abs(touch.clientX - _drag.startX); - const dy = Math.abs(touch.clientY - _drag.startY); - if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht - _activateDrag(touch); + const dx = Math.abs(e.clientX - _drag.startX); + const dy = Math.abs(e.clientY - _drag.startY); + if (dx < 8 && dy < 8) return; + _activateDrag(e); } - e.preventDefault(); // Scroll erst NACH Threshold blockieren - _drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px'; - _drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px'; + _drag.ghost.style.left = (e.clientX - _drag.ox) + 'px'; + _drag.ghost.style.top = (e.clientY - _drag.oy) + 'px'; let foundZone = null; ov.querySelectorAll('.wc-zone').forEach(z => { const r = z.getBoundingClientRect(); - const over = touch.clientX >= r.left && touch.clientX <= r.right - && touch.clientY >= r.top && touch.clientY <= r.bottom; + const over = e.clientX >= r.left && e.clientX <= r.right + && e.clientY >= r.top && e.clientY <= r.bottom; z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent'; if (over) foundZone = z.dataset.zone; }); @@ -967,6 +982,19 @@ window.Worlds = (() => { let _bgUrl = null; // aktuell gesetztes Hintergrundbild + function _isDarkMode() { + const t = document.documentElement.getAttribute('data-theme'); + if (t === 'dark') return true; + if (t === 'light') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + function _bgWithOverlay(url) { + return _isDarkMode() + ? `linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.45)), url('${url}')` + : `url('${url}')`; + } + function _applyBgOrientation() { const ov = document.getElementById('worlds-overlay'); const track = document.getElementById('worlds-track'); @@ -975,14 +1003,14 @@ window.Worlds = (() => { if (portrait) { // Panorama: Bild über alle drei Welten, scrollt mit dem Swipe ov.style.backgroundImage = ''; - track.style.backgroundImage = `url('${_bgUrl}')`; + track.style.backgroundImage = _bgWithOverlay(_bgUrl); track.style.backgroundSize = 'cover'; track.style.backgroundPosition = 'center 40%'; track.style.backgroundRepeat = 'no-repeat'; } else { // Vollbild pro Welt (Landscape / Desktop) track.style.backgroundImage = ''; - ov.style.backgroundImage = `url('${_bgUrl}')`; + ov.style.backgroundImage = _bgWithOverlay(_bgUrl); ov.style.backgroundSize = 'cover'; ov.style.backgroundPosition = 'center 40%'; ov.style.backgroundRepeat = 'no-repeat'; @@ -992,6 +1020,10 @@ window.Worlds = (() => { // Orientierungswechsel → Bild neu setzen window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation); + // Theme-Wechsel → Overlay-Intensität anpassen + new MutationObserver(_applyBgOrientation) + .observe(document.documentElement, { attributeFilter: ['data-theme'] }); + function _applyBgImage(url) { const ov = document.getElementById('worlds-overlay'); const track = document.getElementById('worlds-track'); @@ -1002,7 +1034,20 @@ window.Worlds = (() => { _hasBgPhoto = true; _bgUrl = url; _applyBgOrientation(); - document.getElementById('wh-photo-hint')?.remove(); + const hint = document.getElementById('wh-photo-hint'); + if (hint) { + const seen = parseInt(localStorage.getItem('banyaro_wh_hint_seen') || '0'); + if (seen < 2) { + localStorage.setItem('banyaro_wh_hint_seen', String(seen + 1)); + setTimeout(() => { + hint.style.transition = 'opacity 0.6s'; + hint.style.opacity = '0'; + setTimeout(() => hint.remove(), 650); + }, 4000); + } else { + hint.remove(); + } + } }; toLoad.onerror = () => _applyBgImage(null); toLoad.src = url; @@ -1077,9 +1122,15 @@ window.Worlds = (() => { } else if (!dog) { _applyBgImage(null); } const hour = new Date().getHours(); - const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend'; const firstName = user?.name?.split(' ')[0] || ''; const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); + + // User-Geburtstag heute? + const _todayDdMm = (() => { const d = new Date(); return String(d.getDate()).padStart(2,'0')+'.'+String(d.getMonth()+1).padStart(2,'0'); })(); + const userBdayToday = user?.geburtstag && user.geburtstag === _todayDdMm; + const greet = userBdayToday + ? `Herzlichen Glückwunsch` + : (hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend'); const stale = isOffline && staleMin > 5 ? `· Offline` : ''; @@ -1104,6 +1155,26 @@ window.Worlds = (() => { : (w.temp_c ?? 20) < 2 ? '🌨️' : '☀️'; + // User-Geburtstag Reminder + const userBdayHtml = userBdayToday ? ` +
+
+ + + + + + +
+
+ Alles Gute zum Geburtstag, ${_esc(firstName)}! +
+
+ Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾 +
+
` : ''; + // Alert-Reminder const alertHtml = alertList.slice(0,1).map(a => `
@@ -1150,6 +1221,7 @@ window.Worlds = (() => { ${user ? userAvatarHtml : ''}
+ ${userBdayHtml} ${alertHtml} ${user && dog ? `
@@ -1186,7 +1258,6 @@ window.Worlds = (() => {
` : ''}
-
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
@@ -1209,8 +1280,7 @@ window.Worlds = (() => { try { const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`); const ex = res.data?.daily_exercise; - valEl.textContent = ex?.name || '—'; - // Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt + valEl.textContent = ex?.name || 'Stand erfassen →'; const chip = document.getElementById('wj-exercise-chip'); if (chip) { chip.style.cursor = 'pointer'; @@ -1221,7 +1291,7 @@ window.Worlds = (() => { ); }; } - } catch { valEl.textContent = '—'; } + } catch { valEl.textContent = 'Stand erfassen →'; } } async function _loadJetztRoute() { @@ -1342,8 +1412,13 @@ window.Worlds = (() => { if (mmdd === `${mt}-${dt}`) return 'tomorrow'; return null; } - const bday = _birthdayState(dog.geburtstag); - const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; + const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null; + // Großes Banner nur wenn der AKTIVE Hund Geburtstag hat + const bday = (bdayDog && bdayDog.id === dog.id) ? _birthdayState(dog.geburtstag) : null; + const bdayYear = bday && dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; + + // Hinweis in Info-Karte wenn ein ANDERER Hund Geburtstag hat + const otherBdayDog = (bdayDog && bdayDog.id !== dog.id) ? bdayDog : null; const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), @@ -1402,6 +1477,22 @@ window.Worlds = (() => {
${otherAvatarsHtml}
+ ${otherBdayDog ? ` +
+ + + + + ${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag! + + +
` : ''}
${bday ? `