diff --git a/backend/database.py b/backend/database.py index 7368eb7..f5aee7b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2398,65 +2398,6 @@ 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 5c47ee1..344fe4f 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -5,18 +5,12 @@ 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 @@ -30,77 +24,18 @@ 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", "") 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_PASS = os.getenv("SMTP_PASS", "") 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, attachments: list | None = None): +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" from_raw = SMTP_FROM if "<" in from_raw: from_name = from_raw[:from_raw.index("<")].strip() @@ -117,14 +52,6 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: "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, @@ -137,50 +64,30 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: # ------------------------------------------------------------------ # SMTP Fallback # ------------------------------------------------------------------ -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")) - +def _send_smtp_sync(to: str, subject: str, html: str, plain: str): + msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = SMTP_FROM msg["To"] = to - msg["Date"] = formatdate(localtime=False) + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) - 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_bytes) - _imap_save_sent(msg_bytes) + s.sendmail(SMTP_FROM, [to], msg.as_string()) # ------------------------------------------------------------------ # Öffentliche Funktion # ------------------------------------------------------------------ -async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): +async def send_email(to: str, subject: str, html: str, plain: str = ""): if BREVO_API_KEY: try: - await _send_brevo(to, subject, html, plain, attachments) + await _send_brevo(to, subject, html, plain) 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}") @@ -189,9 +96,7 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme if SMTP_HOST: loop = asyncio.get_event_loop() try: - await loop.run_in_executor( - None, _send_smtp_sync, to, subject, html, plain, attachments - ) + await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) 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 d63ae64..f1025de 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,8 +253,6 @@ 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"]) @@ -319,8 +317,6 @@ 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) # ------------------------------------------------------------------ @@ -410,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -1724,8 +1720,8 @@ async def force_update(): height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px} p{color:#94a3b8;font-size:14px} -
⏳ Einen Moment…
-

Wir besorgen neue Leckerlis 🦴

+
⏳ Aktualisiere Ban Yaro…
+

Service Worker wird entfernt…

- - - + + + @@ -296,7 +296,6 @@
Impressum Datenschutz - AGB
-
-
-
-
@@ -492,10 +487,6 @@
-
-
-
-
@@ -512,14 +503,6 @@
-
-
-
- -
-
-
-
@@ -616,10 +599,10 @@ - - - - + + + + @@ -637,16 +620,6 @@ } 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) { @@ -705,7 +678,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 (!window._BY_SW_RELOAD) { + if (!location.search.includes('_t=')) { 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 1bec4db..6594b45 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,13 +3,11 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -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 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 IS_STAGING = location.hostname === 'staging.banyaro.app'; -// 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, '', '/'); +// Cache-Bust-Parameter nach Update-Reload sofort entfernen +if (location.search.includes('_t=')) history.replaceState(null, '', '/'); const App = (() => { @@ -66,19 +64,15 @@ 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 }, - 'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true }, - litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true }, + breeder: { title: 'Züchter-Profil', module: null }, + 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 }, @@ -261,8 +255,6 @@ 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({ @@ -273,13 +265,10 @@ const App = (() => { page.module = {}; // verhindert erneutes Laden } } catch { - const _offline = !navigator.onLine; container.innerHTML = UI.emptyState({ - icon: _offline ? '📡' : '🚧', + icon: '🚧', title: pages[pageId].title, - 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.', + text: 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; } finally { @@ -287,23 +276,6 @@ 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 // ---------------------------------------------------------- @@ -613,16 +585,11 @@ 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?.(); } @@ -1173,21 +1140,6 @@ 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 b7acbf5..8d8e780 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -27,7 +27,6 @@ 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' }, ]; // ------------------------------------------------------------------ @@ -56,17 +55,17 @@ window.Page_admin = (() => {
- -
-
- ${TABS.map(t => ` - - `).join('')} -
-
+ +
+ ${TABS.map(t => ` + + `).join('')}
+ + +
`; _container.querySelector('#adm-tabs') @@ -98,7 +97,6 @@ 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); @@ -168,7 +166,6 @@ 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.'); @@ -3531,14 +3528,8 @@ 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 ? `
@@ -3546,25 +3537,12 @@ window.Page_admin = (() => {
` : ''}
-
- - -
+ `; // Erledigte als kompakte Tabellenzeilen @@ -3610,7 +3588,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.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`, + message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`, confirmText: 'Freischalten', danger: false, }); @@ -3619,14 +3597,7 @@ window.Page_admin = (() => { btn.textContent = '…'; try { const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); - 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.`); - } + UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); _renderTab(); _renderActionItems(); } catch (e) { @@ -3636,867 +3607,6 @@ 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 deleted file mode 100644 index 9a1c2e2..0000000 --- a/backend/static/js/pages/agb.js +++ /dev/null @@ -1,195 +0,0 @@ -/* ============================================================ - 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 893883f..425536f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -32,26 +32,6 @@ 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 @@ -90,9 +70,6 @@ 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', ` @@ -115,13 +92,6 @@ 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, @@ -157,12 +127,6 @@ 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', ` @@ -215,16 +179,6 @@ 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 @@ -279,28 +233,11 @@ 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 18, 91522 Ansbach
- poststelle@lda.bayern.de · + Promenade 27, 91522 Ansbach
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 — @@ -309,14 +246,8 @@ 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 3 + Stand: Mai 2026 · Version 2

diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index d15c9b5..f04bf67 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -6,8 +6,6 @@ window.Page_diary = (() => { - const _CACHE_KEY = 'by_diary_cache'; - // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -326,7 +324,6 @@ 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; @@ -334,10 +331,6 @@ 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) { @@ -346,17 +339,7 @@ window.Page_diary = (() => { // Stats-Bar befüllen _renderStatsBar(); - } 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 {} + } catch (err) { UI.toast.error('Einträge konnten nicht geladen werden.'); } } @@ -1765,7 +1748,6 @@ 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 e05776d..ffccb44 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -24,58 +24,12 @@ window.Page_impressum = (() => {

Kontakt

-

+

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

- -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
- - -
@@ -92,6 +46,9 @@ 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).

@@ -104,73 +61,20 @@ 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 (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. + Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir + keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer.

- Stand: Mai 2026 + Stand: April 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(container) { - _origInit(container); - _initContactForm(container); - }, - refresh - }; + return { init, refresh }; })(); diff --git a/backend/static/js/pages/laeufi.js b/backend/static/js/pages/laeufi.js index e0fdb68..de09370 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 f6f1883..8f86a86 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', cls: 'badge-warning' }, - geboren: { label: 'Geboren', cls: 'badge-primary' }, - verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, - abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' }, + geplant: { label: 'Geplant', color: '#6B7280' }, + geboren: { label: 'Geboren', color: '#3B82F6' }, + verfuegbar: { label: 'Verfügbar', color: '#22C55E' }, + abgeschlossen: { label: 'Abgeschlossen', color: '#374151' }, }; - const s = map[status] || { label: status, cls: 'badge-muted' }; - return `${_esc(s.label)}`; + const s = map[status] || { label: status, color: '#6B7280' }; + return `${_esc(s.label)}`; } function _fmtDate(iso) { @@ -54,12 +54,12 @@ window.Page_litters = (() => { function _puppyStatusBadge(status) { const map = { - verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, - reserviert: { label: 'Reserviert', cls: 'badge-warning' }, - abgegeben: { label: 'Abgegeben', cls: 'badge-muted' }, + verfuegbar: { label: 'Verfügbar', color: '#22C55E' }, + reserviert: { label: 'Reserviert', color: '#F59E0B' }, + abgegeben: { label: 'Abgegeben', color: '#6B7280' }, }; - const s = map[status] || { label: status, cls: 'badge-muted' }; - return `${_esc(s.label)}`; + const s = map[status] || { label: status, color: '#9CA3AF' }; + 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 6f8fe0c..086224e 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -5,72 +5,17 @@ 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 _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); - } + let _container = null; + let _appState = null; + let _map = null; + let _markers = []; + let _userMarker = null; + let _reports = []; + let _userPos = null; + let _leafletLoaded = false; // ---------------------------------------------------------- // INIT @@ -168,7 +113,6 @@ window.Page_lost = (() => { // KARTE INITIALISIEREN // ---------------------------------------------------------- function _initMap() { - _injectStyles(); const mapEl = document.getElementById('lost-map'); if (!mapEl || !window.L || _map) return; @@ -236,23 +180,7 @@ window.Page_lost = (() => { } try { - 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]; + _reports = await API.lost.list(_userPos.lat, _userPos.lon, 25); _renderMarkers(); _renderHeld(); _renderList(); @@ -263,31 +191,6 @@ 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.'); } } @@ -301,21 +204,20 @@ 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 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) + ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; const marker = L.marker([r.lat, r.lon], { icon }) @@ -324,11 +226,10 @@ window.Page_lost = (() => { 🔍 ${_escape(r.name)}
${r.rasse ? _escape(r.rasse) + '
' : ''} ${distStr ? `📍 ${distStr} entfernt
` : ''} - ${r._isPending ? '⏳ Sync ausstehend
' : ''} 📅 ${_fmtDate(r.created_at)} `); - if (!r._isPending) marker.on('click', () => _openDetail(r)); + marker.on('click', () => _openDetail(r)); _markers.push(marker); }); } @@ -370,19 +271,10 @@ window.Page_lost = (() => { listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.querySelectorAll('[data-lost-id]').forEach(card => { card.addEventListener('click', () => { - const id = card.dataset.lostId; - const r = _reports.find(x => String(x.id) === id && !x._isPending); + const r = _reports.find(x => x.id === parseInt(card.dataset.lostId)); 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(); @@ -440,24 +332,14 @@ window.Page_lost = (() => { Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
- ${r._isPending - ? `
- ⏳ Sync ausstehend - -
` - : (_appState.user ? `
- -
` : '')} + ${_appState.user ? `
+ +
` : ''}
@@ -468,7 +350,6 @@ 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 @@ -751,23 +632,7 @@ window.Page_lost = (() => { client_time : API.clientNow(), }; - 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 - } + const created = await API.lost.report(payload); // Foto hochladen if (photoInput?.files[0]) { diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 36dbc46..bc65d98 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -11,11 +11,9 @@ window.Page_map = (() => { let _map = null; let _leafletLoaded = false; let _userPos = null; - let _weatherLoaded = false; - let _placingMarker = false; - let _tempMarker = null; - let _tileLayer = null; - let _themeObserver = null; + let _weatherLoaded = false; + let _placingMarker = false; + let _tempMarker = null; // Standort-Tracking let _locationMarker = null; @@ -60,8 +58,7 @@ window.Page_map = (() => { zuechter: [], }; - const VISIBLE_KEY = 'by_map_visible_v1'; - const _MAP_POI_KEY = 'by_map_pois_cache'; + const VISIBLE_KEY = 'by_map_visible_v1'; let _visible = {}; // Gespeicherten Zustand laden, Fallback: alles sichtbar @@ -78,7 +75,7 @@ window.Page_map = (() => { // z: zIndexOffset — höher = weiter oben bei Überlappung const TYPEN = { - restaurant: { icon: '', label: 'Café & Restaurant', color: '#F97316', z: 10 }, + restaurant: { icon: '', label: 'Hundefreundl. 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 }, @@ -95,7 +92,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: 'Hotel', color: '#0369a1', z: 20 }, + hotel: { icon: '', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 }, }; // Frontend-Layer → Backend-Typ Mapping @@ -183,7 +180,6 @@ window.Page_map = (() => {
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
`, + border:2px solid rgba(255,255,255,0.8)">${n}`, iconSize: [36, 36], iconAnchor: [18, 18], }); }, @@ -625,34 +612,14 @@ 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 = `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'; } + 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'; } } function _setOsmStatus(text, pct = null) { @@ -970,7 +937,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(52,68,36,0.55)">${t.icon}`, + border:2px solid rgba(255,255,255,0.7)">${t.icon}`, iconSize: [32, 32], iconAnchor: [16, 16], }); @@ -1250,41 +1217,9 @@ window.Page_map = (() => { API.breeder.mapMarkers(), ]); - 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 {} - } - + if (places.status === 'fulfilled') _addPlaces(places.value); + if (poisonList.status === 'fulfilled') _addPoison(poisonList.value); + if (breederList.status === 'fulfilled') _addBreeders(breederList.value); _scheduleOsmLoad(); } @@ -1335,7 +1270,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(52,68,36,0.55)">${t.icon}`, + border:2px solid rgba(255,255,255,0.7)">${t.icon}`, iconSize: [32, 32], iconAnchor: [16, 16], }); @@ -1379,7 +1314,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(52,68,36,0.55)">${t.icon}`, + border:2px solid rgba(255,255,255,0.7)">${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 150fddc..68e7c50 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -5,8 +5,6 @@ window.Page_poison = (() => { - const _CACHE_KEY = 'by_poison_cache'; - // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -173,7 +171,6 @@ 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); @@ -183,18 +180,6 @@ 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.'); } } @@ -543,12 +528,6 @@ 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 { @@ -561,7 +540,8 @@ window.Page_poison = (() => { } } - // Distanz client-seitig berechnen + // Distanz client-seitig berechnen (für sofortige Anzeige) + // _userPos aktualisieren falls Picker neuen Standort geliefert hat 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)) @@ -573,45 +553,12 @@ window.Page_poison = (() => { _updateBadge(_reports.length); App.checkNearbyAlerts(); App.callModule('map', 'refresh'); - _showPoisonThanks(false); + UI.toast.success('Giftköder gemeldet! Danke für die Warnung.'); + UI.modal.close(); }); }); } - // ---------------------------------------------------------- - // 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 b09e4fb..80cffa9 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -5,40 +5,6 @@ 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 = []; @@ -668,8 +634,7 @@ window.Page_routes = (() => { if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (_recOvl) return; - try { await (UI.loadLeaflet?.() ?? Promise.resolve()); } - catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); } + await UI.loadLeaflet?.() ?? Promise.resolve(); const ovl = document.createElement('div'); ovl.id = 'rk-rec-ovl'; @@ -726,37 +691,24 @@ window.Page_routes = (() => { document.body.appendChild(ovl); _recOvl = ovl; - // 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); - - // 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 -
`; - } + _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); - // Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS) + // Get accurate position 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() { @@ -780,7 +732,6 @@ 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; @@ -398,8 +357,7 @@ window.Page_settings = (() => { } try { - const widerrufAt = new Date().toLocaleString('de-DE'); - const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`); + const res = await API.auth.upgradeRequest(tier); UI.modal.close(); if (res.already) { UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.'); @@ -1177,22 +1135,6 @@ 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.
-
@@ -1219,11 +1161,8 @@ 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 d1af781..010d493 100644 --- a/backend/static/js/pages/social.js +++ b/backend/static/js/pages/social.js @@ -43,7 +43,6 @@ window.Page_social = (() => { function _render() { const lvlBar = _stats ? _levelBar(_stats) : ''; _el.innerHTML = ` -
@@ -72,8 +71,7 @@ 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 9410e0a..c6ba308 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -56,7 +56,6 @@ window.Page_uebungen = (() => { { id: 'welpe-basics', label: 'Welpe Basics' }, { id: 'grundlagen', label: 'Trainingsgrundlagen' }, { id: 'ki-trainer', label: 'KI-Trainer' }, - { id: 'verlauf', label: 'Protokoll' }, ]; // ---------------------------------------------------------- @@ -542,15 +541,11 @@ window.Page_uebungen = (() => { _renderContent(); } function onDogChange() { - _statsData = null; - _badgesData = null; - _progressCache = {}; + _statsData = null; + _badgesData = null; + _progressCache = {}; _progressLoaded = false; - _exerciseStats = {}; - _verlaufSessions = []; - _verlaufOffset = 0; - _verlaufLoading = false; - _verlaufView = 'datum'; + _exerciseStats = {}; _render(); _loadStatsAndBadges(); _loadVirtualTrainer(); @@ -563,21 +558,34 @@ window.Page_uebungen = (() => { _container.innerHTML = `
${UI.dogChip(_appState)}
-
- - +
+ + + + + +
+ + + +
${_renderTabs()}
@@ -972,11 +980,10 @@ 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 = isExerciseTab ? 'flex' : 'none'; + if (quickWrap) quickWrap.style.display = showIf(isExerciseTab); const trainerEl = _container.querySelector('#ueb-trainer'); const suggestEl = _container.querySelector('#ueb-suggestions'); const bannerEl = _container.querySelector('#ueb-stats-banner'); @@ -1004,17 +1011,6 @@ 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 = `
@@ -1651,6 +1647,18 @@ window.Page_uebungen = (() => { background:var(--c-surface);color:var(--c-text);line-height:1.5">
+ + + @@ -1706,6 +1714,7 @@ window.Page_uebungen = (() => { btn.style.background = 'var(--c-primary-subtle)'; btn.style.borderColor = 'var(--c-primary)'; btn.style.transform = 'scale(1.15)'; + _checkMilestoneVisibility(); }); }); @@ -1729,9 +1738,17 @@ 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(); @@ -1744,17 +1761,20 @@ 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, + 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, }; try { @@ -1786,6 +1806,12 @@ 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(); @@ -1969,325 +1995,6 @@ 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 e0c6c40..d925a29 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -5,45 +5,6 @@ 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 = []; @@ -234,16 +195,14 @@ window.Page_walks = (() => { // Daten laden // ---------------------------------------------------------- async function _loadData() { - const pending = _getPending(); try { - const fetched = await API.walks.list( + _data = 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(); @@ -251,20 +210,8 @@ window.Page_walks = (() => { setTimeout(() => _map?.invalidateSize(), 400); }); } - } 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.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Laden.'); } } @@ -344,7 +291,6 @@ window.Page_walks = (() => { ${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer} ${isOwn ? 'Mein Treffen' : ''} - ${w._isPending ? `⏳ Sync ausstehend` : ''}
@@ -1182,30 +1128,15 @@ 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 { - 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; } + const created = await API.walks.create(payload); _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 256f499..9e60fb0 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -167,8 +167,6 @@ window.Page_welcome = (() => { Impressum  ·  Datenschutz -  ·  - AGB

`; diff --git a/backend/static/js/pages/zuchthunde.js b/backend/static/js/pages/zuchthunde.js index 9798b02..0d4d96f 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 9da043f..a0b69b1 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -83,19 +83,13 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - // Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen + // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint let _vvCleanup = null; const vv = window.visualViewport; - const modal = overlay.querySelector('.modal'); if (vv) { const adjust = () => { - 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'; + const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + overlay.style.paddingBottom = (kb + 16) + 'px'; }; vv.addEventListener('resize', adjust); vv.addEventListener('scroll', adjust); @@ -103,37 +97,19 @@ const UI = (() => { vv.removeEventListener('resize', adjust); vv.removeEventListener('scroll', adjust); overlay.style.paddingBottom = ''; - if (modal) modal.style.maxHeight = ''; }; } - // 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 }; + _current = { overlay, onClose, _vvCleanup }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; - const { onClose, _vvCleanup, _onFocusin } = _current; + const { onClose, _vvCleanup } = _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 7459eeb..b250a29 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -598,21 +598,13 @@ window.Worlds = (() => { let _cfgCache = null; function _mergeDefaults(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 || []), - ]); + // Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen + const result = JSON.parse(JSON.stringify(cfg)); for (const world of ['jetzt', 'hund', 'welt']) { const def = _DEFAULT_CONFIG[world] || []; const saved = result[world] || []; for (const page of def) { - // 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); - } + if (!saved.includes(page)) saved.push(page); } result[world] = saved; } @@ -645,11 +637,6 @@ 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) { @@ -723,18 +710,15 @@ 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 = _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'; + ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end'; document.body.appendChild(ov); const _removeDragListeners = () => { - document.removeEventListener('pointermove', _onDragMove); - document.removeEventListener('pointerup', _onDragEnd); - document.removeEventListener('pointercancel', _onDragEnd); + document.removeEventListener('touchmove', _onDragMove); + document.removeEventListener('touchend', _onDragEnd); + document.removeEventListener('touchcancel', _onDragEnd); }; const _cancelDrag = () => { if (!_drag) return; @@ -752,14 +736,11 @@ 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 ? ` @@ -874,27 +855,29 @@ window.Worlds = (() => { }); }); - // Pointer-Drag (funktioniert auf Mouse + Touch) + // Touch-Drag ov.querySelectorAll('.wc-chip').forEach(chip => { - chip.addEventListener('pointerdown', e => _onDragStart(e, chip)); + chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true }); }); + 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(); - chipEl.setPointerCapture(e.pointerId); + const touch = e.touches[0]; + // Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte) _drag = { page: chipEl.dataset.page, zone: chipEl.dataset.zone, chipEl, ghost: null, dropZone: null, active: false, - startX: e.clientX, startY: e.clientY, ox: 0, oy: 0, + startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0, }; - document.addEventListener('pointermove', _onDragMove); - document.addEventListener('pointerup', _onDragEnd); - document.addEventListener('pointercancel', _onDragEnd); + document.addEventListener('touchmove', _onDragMove, { passive: false }); + document.addEventListener('touchend', _onDragEnd); + document.addEventListener('touchcancel', _onDragEnd); } - function _activateDrag(e) { + function _activateDrag(touch) { const rect = _drag.chipEl.getBoundingClientRect(); _drag.ox = _drag.startX - rect.left; _drag.oy = _drag.startY - rect.top; @@ -908,8 +891,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 = (e.clientX - _drag.ox) + 'px'; - ghost.style.top = (e.clientY - _drag.oy) + 'px'; + ghost.style.left = (touch.clientX - _drag.ox) + 'px'; + ghost.style.top = (touch.clientY - _drag.oy) + 'px'; ghost.style.transition = 'none'; ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; document.body.appendChild(ghost); @@ -919,22 +902,24 @@ window.Worlds = (() => { function _onDragMove(e) { if (!_drag) return; + const touch = e.touches[0]; if (!_drag.active) { - const dx = Math.abs(e.clientX - _drag.startX); - const dy = Math.abs(e.clientY - _drag.startY); - if (dx < 8 && dy < 8) return; - _activateDrag(e); + 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); } - _drag.ghost.style.left = (e.clientX - _drag.ox) + 'px'; - _drag.ghost.style.top = (e.clientY - _drag.oy) + 'px'; + 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'; let foundZone = null; ov.querySelectorAll('.wc-zone').forEach(z => { const r = z.getBoundingClientRect(); - const over = e.clientX >= r.left && e.clientX <= r.right - && e.clientY >= r.top && e.clientY <= r.bottom; + const over = touch.clientX >= r.left && touch.clientX <= r.right + && touch.clientY >= r.top && touch.clientY <= r.bottom; z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent'; if (over) foundZone = z.dataset.zone; }); @@ -982,19 +967,6 @@ 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'); @@ -1003,14 +975,14 @@ window.Worlds = (() => { if (portrait) { // Panorama: Bild über alle drei Welten, scrollt mit dem Swipe ov.style.backgroundImage = ''; - track.style.backgroundImage = _bgWithOverlay(_bgUrl); + track.style.backgroundImage = `url('${_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 = _bgWithOverlay(_bgUrl); + ov.style.backgroundImage = `url('${_bgUrl}')`; ov.style.backgroundSize = 'cover'; ov.style.backgroundPosition = 'center 40%'; ov.style.backgroundRepeat = 'no-repeat'; @@ -1020,10 +992,6 @@ 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'); @@ -1034,20 +1002,7 @@ window.Worlds = (() => { _hasBgPhoto = true; _bgUrl = url; _applyBgOrientation(); - 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(); - } - } + document.getElementById('wh-photo-hint')?.remove(); }; toLoad.onerror = () => _applyBgImage(null); toLoad.src = url; @@ -1122,15 +1077,9 @@ 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` : ''; @@ -1155,26 +1104,6 @@ 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 => `
@@ -1221,7 +1150,6 @@ window.Worlds = (() => { ${user ? userAvatarHtml : ''}
- ${userBdayHtml} ${alertHtml} ${user && dog ? `
@@ -1258,6 +1186,7 @@ window.Worlds = (() => {
` : ''}
+
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
@@ -1280,7 +1209,8 @@ 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 || 'Stand erfassen →'; + valEl.textContent = ex?.name || '—'; + // Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt const chip = document.getElementById('wj-exercise-chip'); if (chip) { chip.style.cursor = 'pointer'; @@ -1291,7 +1221,7 @@ window.Worlds = (() => { ); }; } - } catch { valEl.textContent = 'Stand erfassen →'; } + } catch { valEl.textContent = '—'; } } async function _loadJetztRoute() { @@ -1412,13 +1342,8 @@ window.Worlds = (() => { if (mmdd === `${mt}-${dt}`) return 'tomorrow'; return 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 bday = _birthdayState(dog.geburtstag); + const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), @@ -1477,22 +1402,6 @@ window.Worlds = (() => {
${otherAvatarsHtml}
- ${otherBdayDog ? ` -
- - - - - ${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag! - - -
` : ''}
${bday ? `