diff --git a/backend/database.py b/backend/database.py
index f5aee7b..7368eb7 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -2398,6 +2398,65 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
+ # Rechnungs-System
+ try:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS invoices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ invoice_number TEXT NOT NULL UNIQUE,
+ user_id INTEGER REFERENCES users(id),
+ recipient_name TEXT NOT NULL,
+ recipient_email TEXT NOT NULL,
+ recipient_address TEXT,
+ description TEXT NOT NULL,
+ service_period TEXT,
+ amount_net REAL NOT NULL,
+ discount_pct REAL DEFAULT 0,
+ discount_amount REAL DEFAULT 0,
+ amount_after_discount REAL NOT NULL,
+ tax_rate REAL DEFAULT 0,
+ tax_amount REAL DEFAULT 0,
+ amount_gross REAL NOT NULL,
+ status TEXT DEFAULT 'draft',
+ notes TEXT,
+ created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ sent_at TEXT,
+ paid_at TEXT,
+ paid_amount REAL,
+ cancelled_at TEXT,
+ cancellation_reason TEXT,
+ cancellation_number TEXT
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS invoice_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
+ description TEXT NOT NULL,
+ quantity REAL NOT NULL DEFAULT 1,
+ unit_price REAL NOT NULL,
+ total REAL NOT NULL
+ )
+ """)
+ logger.info("Migration: invoices + invoice_items bereit.")
+ except Exception as e:
+ logger.warning(f"Migration invoices: {e}")
+
+ try:
+ conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT")
+ logger.info("Migration: billing_address bereit.")
+ except Exception:
+ pass
+
+ existing_u_gb = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'geburtstag' not in existing_u_gb:
+ conn.execute("ALTER TABLE users ADD COLUMN geburtstag TEXT")
+ logger.info("Migration: users.geburtstag hinzugefügt.")
+ else:
+ logger.info("Migration: users.geburtstag bereits vorhanden.")
+
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
diff --git a/backend/mailer.py b/backend/mailer.py
index 344fe4f..5c47ee1 100644
--- a/backend/mailer.py
+++ b/backend/mailer.py
@@ -5,12 +5,18 @@ Unterstützt zwei Backends (wird automatisch gewählt):
2. SMTP — wenn SMTP_HOST gesetzt (Fallback)
"""
+import imaplib
import os
+import base64
import smtplib
import asyncio
import logging
+import ssl
+from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
+from email.mime.application import MIMEApplication
+from email.utils import formatdate
import httpx
@@ -24,18 +30,77 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
-SMTP_PASS = os.getenv("SMTP_PASS", "")
+SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "")
+
+# IMAP für Gesendet-Ordner
+IMAP_HOST = os.getenv("IMAP_HOST", SMTP_HOST)
+IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
+_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro ")
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
+# ------------------------------------------------------------------
+# IMAP: Mail in Gesendet-Ordner speichern
+# ------------------------------------------------------------------
+def _imap_save_sent(msg_bytes: bytes):
+ if not IMAP_HOST or not SMTP_USER or not SMTP_PASS:
+ return
+ try:
+ ctx = ssl.create_default_context()
+ with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=ctx) as imap:
+ imap.login(SMTP_USER, SMTP_PASS)
+ _, raw_folders = imap.list()
+ available = [f.decode(errors="replace") for f in (raw_folders or [])]
+ folder = None
+ for line in available:
+ name = line.rsplit('"." ', 1)[-1].strip().strip('"')
+ for candidate in _SENT_CANDIDATES:
+ if candidate.lower() in name.lower():
+ folder = name
+ break
+ if folder:
+ break
+ if not folder:
+ folder = "INBOX.Sent"
+ imap.append(
+ folder,
+ r"\Seen",
+ imaplib.Time2Internaldate(datetime.now().timestamp()),
+ msg_bytes,
+ )
+ except Exception as e:
+ logger.warning(f"IMAP Gesendet-Speicherung fehlgeschlagen: {e}")
+
+
+def _build_mime_copy(to: str, subject: str, html: str, plain: str, attachments: list | None) -> MIMEMultipart:
+ """Baut eine MIME-Nachricht für die Gesendet-Ablage (Brevo-Pfad)."""
+ if attachments:
+ msg = MIMEMultipart("mixed")
+ alt = MIMEMultipart("alternative")
+ alt.attach(MIMEText(plain, "plain", "utf-8"))
+ alt.attach(MIMEText(html, "html", "utf-8"))
+ msg.attach(alt)
+ for a in attachments:
+ part = MIMEApplication(a["content"], Name=a["filename"])
+ part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
+ msg.attach(part)
+ else:
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEText(plain, "plain", "utf-8"))
+ msg.attach(MIMEText(html, "html", "utf-8"))
+ msg["Subject"] = subject
+ msg["From"] = SMTP_FROM
+ msg["To"] = to
+ msg["Date"] = formatdate(localtime=False)
+ return msg
+
+
# ------------------------------------------------------------------
# Brevo REST-API
# ------------------------------------------------------------------
-async def _send_brevo(to: str, subject: str, html: str, plain: str):
- # Absender-Name und -Adresse aus SMTP_FROM parsen
- # Format: "Ban Yaro " oder "noreply@banyaro.app"
+async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
from_raw = SMTP_FROM
if "<" in from_raw:
from_name = from_raw[:from_raw.index("<")].strip()
@@ -52,6 +117,14 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str):
"textContent": plain,
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"},
}
+ if attachments:
+ payload["attachment"] = [
+ {
+ "name": a["filename"],
+ "content": base64.b64encode(a["content"]).decode("ascii"),
+ }
+ for a in attachments
+ ]
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
BREVO_API_URL,
@@ -64,30 +137,50 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str):
# ------------------------------------------------------------------
# SMTP Fallback
# ------------------------------------------------------------------
-def _send_smtp_sync(to: str, subject: str, html: str, plain: str):
- msg = MIMEMultipart("alternative")
+def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
+ if attachments:
+ msg = MIMEMultipart("mixed")
+ alt = MIMEMultipart("alternative")
+ alt.attach(MIMEText(plain, "plain", "utf-8"))
+ alt.attach(MIMEText(html, "html", "utf-8"))
+ msg.attach(alt)
+ for a in attachments:
+ part = MIMEApplication(a["content"], Name=a["filename"])
+ part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
+ msg.attach(part)
+ else:
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEText(plain, "plain", "utf-8"))
+ msg.attach(MIMEText(html, "html", "utf-8"))
+
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
- msg.attach(MIMEText(plain, "plain", "utf-8"))
- msg.attach(MIMEText(html, "html", "utf-8"))
+ msg["Date"] = formatdate(localtime=False)
+ msg_bytes = msg.as_bytes()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
s.ehlo()
s.starttls()
if SMTP_USER:
s.login(SMTP_USER, SMTP_PASS)
- s.sendmail(SMTP_FROM, [to], msg.as_string())
+ s.sendmail(SMTP_FROM, [to], msg_bytes)
+ _imap_save_sent(msg_bytes)
# ------------------------------------------------------------------
# Öffentliche Funktion
# ------------------------------------------------------------------
-async def send_email(to: str, subject: str, html: str, plain: str = ""):
+async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None):
if BREVO_API_KEY:
try:
- await _send_brevo(to, subject, html, plain)
+ await _send_brevo(to, subject, html, plain, attachments)
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}")
+ # MIME-Kopie für Gesendet-Ordner konstruieren
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(None, lambda: _imap_save_sent(
+ _build_mime_copy(to, subject, html, plain, attachments).as_bytes()
+ ))
return
except Exception as e:
logger.error(f"Brevo-Fehler: {e}")
@@ -96,7 +189,9 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
if SMTP_HOST:
loop = asyncio.get_event_loop()
try:
- await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain)
+ await loop.run_in_executor(
+ None, _send_smtp_sync, to, subject, html, plain, attachments
+ )
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
return
except Exception as e:
diff --git a/backend/main.py b/backend/main.py
index f1025de..d63ae64 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -253,6 +253,8 @@ from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router
from routes.feedback import router as feedback_router
+from routes.contact import router as contact_router
+from routes.invoices import router as invoices_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -317,6 +319,8 @@ app.include_router(challenges_router, prefix="/api/challenges", ta
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
+app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"])
+app.include_router(invoices_router)
# ------------------------------------------------------------------
@@ -406,7 +410,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
-APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen
+APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
@@ -1720,8 +1724,8 @@ async def force_update():
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px}
-⏳ Aktualisiere Ban Yaro…
-Service Worker wird entfernt…
+⏳ Einen Moment…
+Wir besorgen neue Leckerlis 🦴
-
-
-
+
+
+
@@ -296,6 +296,7 @@
Impressum
Datenschutz
+ AGB
+
+
@@ -487,6 +492,10 @@
+
+
@@ -503,6 +512,14 @@
+
+
+
+
@@ -599,10 +616,10 @@
-
-
-
-
+
+
+
+
@@ -620,6 +637,16 @@
}
window.addEventListener('offline', function() {
_updateBanner();
+ // Einmaliger Hinweis pro Session: App im Vordergrund lassen
+ if (!sessionStorage.getItem('by_offline_hint_shown')) {
+ sessionStorage.setItem('by_offline_hint_shown', '1');
+ setTimeout(function() {
+ window.UI?.toast?.info(
+ 'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
+ 8000
+ );
+ }, 800);
+ }
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
@@ -678,7 +705,7 @@
// Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
- if (!location.search.includes('_t=')) {
+ if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 6594b45..1bec4db 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,11 +3,13 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
-const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
+const APP_VER = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
-// Cache-Bust-Parameter nach Update-Reload sofort entfernen
-if (location.search.includes('_t=')) history.replaceState(null, '', '/');
+// Cache-Bust-Parameter nach Update-Reload sofort entfernen.
+// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach.
+window._BY_SW_RELOAD = location.search.includes('_t=');
+if (window._BY_SW_RELOAD) history.replaceState(null, '', '/');
const App = (() => {
@@ -64,15 +66,19 @@ const App = (() => {
moderation: { title: 'Moderation', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
+ agb: { title: 'AGB', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
- breeder: { title: 'Züchter-Profil', module: null },
- litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
+ breeder: { title: 'Züchter-Profil', module: null },
+ 'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true },
+ litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
+ partner: { title: 'Unsere Partner', module: null },
+ 'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
@@ -255,6 +261,8 @@ const App = (() => {
if (mod?.init) {
await mod.init(container, state, params);
page.module = mod;
+ // Desktop: erste Inhalts-Div auf Standardbreite setzen
+ _applyDesktopWidth(pageId, container);
} else {
// Platzhalter wenn Seite noch nicht gebaut
container.innerHTML = UI.emptyState({
@@ -265,10 +273,13 @@ const App = (() => {
page.module = {}; // verhindert erneutes Laden
}
} catch {
+ const _offline = !navigator.onLine;
container.innerHTML = UI.emptyState({
- icon: '🚧',
+ icon: _offline ? '📡' : '🚧',
title: pages[pageId].title,
- text: 'Diese Seite ist noch in Entwicklung.',
+ text: _offline
+ ? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.'
+ : 'Diese Seite ist noch in Entwicklung.',
});
page.module = {};
} finally {
@@ -276,6 +287,23 @@ const App = (() => {
}
}
+ // ----------------------------------------------------------
+ // DESKTOP WIDTH — einheitliche Breite auf großen Screens
+ // ----------------------------------------------------------
+ const _FULLSCREEN_PAGES = new Set([
+ 'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse',
+ 'routes','walks','litters','zucht-profil','widget',
+ ]);
+ function _applyDesktopWidth(pageId, container) {
+ if (window.innerWidth < 768) return;
+ if (_FULLSCREEN_PAGES.has(pageId)) return;
+ const first = container.querySelector(':scope > div');
+ if (first && !first.classList.contains('page-container') &&
+ !first.classList.contains('pc-desktop')) {
+ first.classList.add('pc-desktop');
+ }
+ }
+
// ----------------------------------------------------------
// LOGIN GATE — wird statt Seiteninhalt angezeigt
// ----------------------------------------------------------
@@ -585,11 +613,16 @@ const App = (() => {
_checkNearbyAlerts();
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
setInterval(_checkNearbyAlerts, 5 * 60_000);
+ // App-Heartbeat: last_seen aktualisieren (Nutzungsfrequenz für Admin)
+ const _sendHeartbeat = () => API.post('/auth/heartbeat', {}).catch(() => {});
+ _sendHeartbeat();
+ setInterval(_sendHeartbeat, 5 * 60_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
_updateNotifBadge();
_updateChatBadge();
_checkNearbyAlerts();
+ _sendHeartbeat();
if (state.page === 'chat') {
pages['chat']?.module?.refresh?.();
}
@@ -1140,6 +1173,21 @@ const App = (() => {
window.App = App; // Worlds kann App.navigate() aufrufen
// App starten
+// Prioritäts-Seiten im Hintergrund vorladen (1s nach Start)
+window.addEventListener('load', () => {
+ setTimeout(() => {
+ if (!navigator.onLine) return;
+ // Page-Scripts cachen
+ [
+ 'admin','erste-hilfe','diary','map','walks','routes','poison','lost',
+ 'expenses','wetter','forum','health','uebungen','trainingsplaene','notes',
+ ].forEach(page => {
+ const key = `Page_${page.replace(/-/g,'_')}`;
+ if (!window[key]) fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {});
+ });
+ }, 1000);
+});
+
document.addEventListener('DOMContentLoaded', () => {
App.init();
if (IS_STAGING) {
diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js
index 8d8e780..b7acbf5 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -27,6 +27,7 @@ window.Page_admin = (() => {
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
+ { id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
];
// ------------------------------------------------------------------
@@ -55,17 +56,17 @@ window.Page_admin = (() => {
-
-
- ${TABS.map(t => `
-
- ${UI.icon(t.icon)} ${t.label}
-
- `).join('')}
+
+
+
+ ${TABS.map(t => `
+
+ ${UI.icon(t.icon)} ${t.label}
+
+ `).join('')}
+
+
-
-
-
`;
_container.querySelector('#adm-tabs')
@@ -97,6 +98,7 @@ window.Page_admin = (() => {
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
+ { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
];
const open = items.filter(i => d[i.key] > 0);
@@ -166,6 +168,7 @@ window.Page_admin = (() => {
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break;
+ case 'rechnungen': await _renderRechnungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@@ -3528,8 +3531,14 @@ window.Page_admin = (() => {
${_esc(r.email)}
${tierBadge(r.tier)}
+ ${r.discount_pct > 0 ? `
+ ${r.discount_pct}% Rabatt ` : ''}
${r.created_at?.slice(0,10) || ''}
+ ${r.discount_reason === 'founder' ? `
Gründer — kostenfrei
` : ''}
+ ${r.discount_reason === 'referred_by_founder' ? `
Von Gründer eingeladen
` : ''}
+ ${r.discount_reason === 'referral' ? `
${r.referral_count} Freunde geworben
` : ''}
${r.message ? `
@@ -3537,12 +3546,25 @@ window.Page_admin = (() => {
` : ''}
-
- ✓ Freischalten
-
+
+
+ ${UI.icon('receipt')} Rechnung erstellen
+
+
+ ✓ Freischalten
+
+
`;
// Erledigte als kompakte Tabellenzeilen
@@ -3588,7 +3610,7 @@ window.Page_admin = (() => {
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const ok = await UI.modal.confirm({
title: `${name} auf ${tierLabel} freischalten?`,
- message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`,
+ message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`,
confirmText: 'Freischalten',
danger: false,
});
@@ -3597,7 +3619,14 @@ window.Page_admin = (() => {
btn.textContent = '…';
try {
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
- UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
+ if (res.invoice_number) {
+ UI.toast.success(
+ `${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`,
+ 6000
+ );
+ } else {
+ UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
+ }
_renderTab();
_renderActionItems();
} catch (e) {
@@ -3607,6 +3636,867 @@ window.Page_admin = (() => {
}
});
});
+
+ // "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
+ const TIER_ITEMS = {
+ pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
+ breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
+ };
+ const _year = new Date().getFullYear();
+ const _now = new Date();
+ const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1);
+ const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
+ const _period = `${_fmt(_now)} - ${_fmt(_end)}`;
+
+ function _discountNote(reason, count, pct, tierLabel) {
+ const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';
+ if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`;
+ if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`;
+ if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`;
+ return agb;
+ }
+
+ el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const { name, email, tier, address } = btn.dataset;
+ const discountPct = Number(btn.dataset.discount) || 0;
+ const discountReason = btn.dataset.discountReason || '';
+ const referralCount = Number(btn.dataset.referralCount) || 0;
+ const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
+ _openNeueRechnungModal(() => {
+ _tab = 'rechnungen';
+ _renderTab();
+ }, {
+ recipient_name: name,
+ recipient_email: email,
+ recipient_address: address || '',
+ service_period: _period,
+ discount_pct: discountPct,
+ notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description),
+ items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
+ });
+ });
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // TAB: RECHNUNGEN
+ // ------------------------------------------------------------------
+ async function _renderRechnungen(el) {
+ let _subView = 'liste'; // 'liste' | 'cashflow'
+
+ async function _load() {
+ el.innerHTML = `
+
+
+
+ ${UI.icon('list-bullets')} Rechnungen
+
+
+ ${UI.icon('chart-bar')} Cashflow
+
+
+ ${_subView === 'liste' ? `
+
+ ${UI.icon('plus')} Neue Rechnung
+ ` : ''}
+
+
+ `;
+
+ 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(`
+ ${UI.icon('pencil')} Bearbeiten
+ `);
+ actions.push(`
+ ${UI.icon('paper-plane-tilt')} Senden
+ `);
+ }
+ if (inv.status === 'sent') {
+ actions.push(`
+ ${UI.icon('paper-plane-tilt')} Erneut senden
+ `);
+ }
+ if (inv.status === 'sent') {
+ actions.push(`
+ ${UI.icon('check-circle')} Bezahlt
+ `);
+ actions.push(`
+ ${UI.icon('x-circle')} Storno
+ `);
+ }
+ if (inv.status === 'paid' || inv.status === 'cancelled') {
+ actions.push(`
+ ${UI.icon('eye')} Details
+ `);
+ }
+
+ 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 = `
+
+ `;
+
+ // 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: `
+
+ `,
+ footer: `
+ Abbrechen
+ ${UI.icon('receipt')} ${isEdit ? 'Änderungen speichern' : 'Rechnung erstellen'}
+ `,
+ });
+
+ // 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 = `
+
+
+
+
+ ${UI.icon('x')}
+
+ `;
+ 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: `
+
+
+ Zahlungsdatum *
+
+
+
+ Eingegangener Betrag (€) *
+
+
+
+
+ `,
+ footer: `
+ Abbrechen
+ ${UI.icon('check-circle')} Als bezahlt markieren
+ `,
+ });
+
+ // 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.
+
+
+ Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)
+ `;
+ } 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.
+
+
+ Stornierungsgrund *
+
+
+
+ `,
+ footer: `
+ Abbrechen
+
+ ${UI.icon('x-circle')} Rechnung stornieren
+
+ `,
+ });
+
+ 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
+
+
+
+ Beschreibung
+ Menge
+ Preis
+ Gesamt
+
+
+ ${itemsHtml}
+
+
+ Gesamt (brutto)
+ ${_fmtEur(inv.amount_gross)}
+
+
+
+
+
+ ${inv.notes ? `
+
+
Notizen
+
${_esc(inv.notes)}
+
` : ''}
+
+ `,
+ footer: `Schließen `,
+ });
+ }
+
+ 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 => `${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
+
+
+
+
+
+
+ ${UI.icon('file-csv')} Quartalsbericht herunterladen
+
+
+
+ Jahr
+ ${years}
+
+
+ Quartal
+
+ Q1 (Jan–Mär)
+ Q2 (Apr–Jun)
+ Q3 (Jul–Sep)
+ Q4 (Okt–Dez)
+
+
+
+ ${UI.icon('download-simple')} CSV herunterladen
+
+
+ ${UI.icon('eye')} Vorschau
+
+
+
+
+ `;
+
+ // 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)}
+
+ `;
+ } catch (e) {
+ resultEl.innerHTML = `Fehler: ${_esc(e.message)}
`;
+ }
+ });
}
return { init, refresh, onDogChange };
diff --git a/backend/static/js/pages/agb.js b/backend/static/js/pages/agb.js
new file mode 100644
index 0000000..9a1c2e2
--- /dev/null
+++ b/backend/static/js/pages/agb.js
@@ -0,0 +1,195 @@
+/* ============================================================
+ BAN YARO — Allgemeine Geschäftsbedingungen
+ ============================================================ */
+
+window.Page_agb = (() => {
+
+ const S = {
+ h2: `font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-primary);margin:0 0 var(--space-2)`,
+ p: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0`,
+ ul: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:var(--space-2) 0 0;padding-left:var(--space-5)`,
+ a: `color:var(--c-primary)`,
+ };
+
+ function sec(title, body) {
+ return `
+ `;
+ }
+
+ function init(container) {
+ container.innerHTML = `
+
+
+
Allgemeine Geschäftsbedingungen
+
Gültig ab Mai 2026
+
+ ${sec('1. Geltungsbereich', `
+
+ Diese AGB gelten für die Nutzung der Plattform Ban Yaro
+ (banyaro.app ), betrieben von:
+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app
+
+
+ Sie gelten ausschließlich für kostenpflichtige Abonnements. Die kostenlose Nutzung
+ der App setzt lediglich die Registrierung voraus.
+
`)}
+
+ ${sec('2. Mindestalter', `
+
+ Die Nutzung von Ban Yaro, insbesondere die Registrierung und der Abschluss eines
+ Abonnements, ist nur Personen ab 18 Jahren gestattet. Mit Abschluss des Vertrags
+ bestätigt der Nutzer, volljährig zu sein.
+
`)}
+
+ ${sec('3. Leistungen', `
+
Ban Yaro bietet folgende kostenpflichtige Abonnements an:
+
+
+ Ban Yaro Pro — 29 EUR/Jahr: Erweiterte App-Funktionen für mehrere
+ Hunde, KI-Features, zusätzliche Karten-Layer, Chat und Playdate-Funktion sowie
+ alle weiteren Pro-Funktionen laut aktuellem Funktionsumfang.
+
+
+ Ban Yaro Züchter — 49 EUR/Jahr: Alle Pro-Funktionen plus
+ Zuchtkartei, Stammbaum, Wurfverwaltung und Züchterprofil.
+
+
+
+ Änderungen am Funktionsumfang werden vorab per E-Mail angekündigt. Wesentliche
+ Leistungsminderungen berechtigen zur außerordentlichen Kündigung.
+
`)}
+
+ ${sec('4. Nutzungsregeln / Community', `
+
Die Nutzung der Plattform-Features (Forum, Chat, Fotos, Kommentare) unterliegt folgenden Regeln:
+
+ Keine rechtswidrigen, beleidigenden, diskriminierenden oder irreführenden Inhalte
+ Kein Spam, keine Werbung ohne Genehmigung, keine Fake-Accounts
+ Respektvoller Umgang mit anderen Nutzern
+ Keine Verletzung von Urheberrechten Dritter bei hochgeladenen Inhalten
+
+
+ Bei Verstoß sind wir berechtigt, Inhalte zu entfernen und Accounts zu sperren oder
+ zu kündigen. Rechtswidrige Inhalte werden unverzüglich entfernt und ggf. Behörden
+ gemeldet. Meldungen können an
+ hallo@banyaro.app
+ gerichtet werden.
+
`)}
+
+ ${sec('5. Nutzerinhalte und Lizenzen', `
+
+ Durch das Hochladen von Inhalten (Fotos, Texte, Beiträge) räumt der Nutzer Ban Yaro
+ eine nicht-exklusive, kostenlose, weltweite Lizenz ein, diese Inhalte im Rahmen der
+ Plattform zu speichern, anzuzeigen und technisch zu verarbeiten. Diese Lizenz erlischt
+ mit Löschung des Inhalts oder Löschung des Accounts. Urheberrechte und sonstige
+ Rechte der Nutzer an ihren Inhalten bleiben unberührt.
+
`)}
+
+ ${sec('6. Preise und Zahlung', `
+
+ Der Jahresbeitrag ist bei Vertragsschluss für die gesamte Laufzeit im Voraus fällig.
+ Die Zahlung erfolgt per Überweisung — IBAN und Verwendungszweck stehen auf der
+ Rechnung, die per E-Mail zugestellt wird. Der Betrag ist innerhalb von
+ 14 Tagen nach Rechnungsstellung zu überweisen.
+
+
+ Bei Zahlungsverzug erhalten Sie zunächst eine Zahlungserinnerung. Bleibt der Betrag
+ danach weiterhin ausstehend, behalten wir uns die fristlose Kündigung des Vertrags
+ gemäß § 314 BGB vor.
+
`)}
+
+ ${sec('7. Vertragslaufzeit und Kündigung', `
+
+ Die Erstlaufzeit beträgt 12 Monate ab dem Tag der Freischaltung.
+ Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit — kündbar jederzeit
+ mit einer Frist von einem Monat zum Monatsende (§ 309 Nr. 9 BGB).
+
+
+ Die Kündigung ist jederzeit in den App-Einstellungen unter
+ Einstellungen → Abonnement → Kündigen möglich (§ 312k BGB).
+ Eine Kündigungsbestätigung wird per E-Mail zugesandt.
+ Der Zugang bleibt bis zum Ende der bereits bezahlten Laufzeit vollständig aktiv.
+
`)}
+
+ ${sec('8. Kein Erstattungsanspruch', `
+
+ Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine anteilige Rückerstattung
+ des Jahresbeitrags. Der Zugang bleibt bis zum Ende der Laufzeit vollständig nutzbar —
+ du verlierst also nichts, was du bereits bezahlt hast.
+ Gesetzliche Ansprüche bei vertragswidrigen Leistungen bleiben unberührt.
+
`)}
+
+ ${sec('9. Widerrufsrecht', `
+
+ Da die Nutzung unmittelbar nach Freischaltung beginnt und du beim Kauf ausdrücklich
+ zustimmst, dass die Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt, erlischt
+ dein 14-tägiges Widerrufsrecht mit Beginn der Nutzung (§ 356 Abs. 4 BGB). Dir ist
+ bekannt, dass du durch diese Zustimmung dein Widerrufsrecht verlierst. Die Zustimmung
+ wird beim Kauf aktiv protokolliert.
+
`)}
+
+ ${sec('10. Fristlose Kündigung durch den Anbieter', `
+
+ Wir sind berechtigt, den Vertrag aus wichtigem Grund fristlos zu kündigen
+ (§ 314 BGB). Ein wichtiger Grund liegt insbesondere vor, wenn nach einer
+ Zahlungserinnerung der offene Betrag weiterhin nicht beglichen wird.
+ In diesem Fall endet der Zugang mit Wirkung der Kündigung.
+
`)}
+
+ ${sec('11. KI-Funktionen / Haftung für KI-Inhalte', `
+
+ KI-generierte Inhalte (Trainer-Empfehlungen, Gesundheitshinweise, Züchter-Analysen)
+ können fehlerhaft oder unvollständig sein. Sie dienen ausschließlich der allgemeinen
+ Information und ersetzen keine tierärztliche, veterinärmedizinische oder fachliche
+ Beratung. Ban Yaro haftet nicht für Schäden, die aus der Nutzung KI-generierter
+ Inhalte entstehen.
+
`)}
+
+ ${sec('12. Verfügbarkeit', `
+
+ Wir streben eine hohe Verfügbarkeit von Ban Yaro an und arbeiten kontinuierlich
+ daran, die App stabil zu halten. Eine Garantie für ununterbrochene Verfügbarkeit
+ können wir jedoch nicht übernehmen. Geplante Wartungsarbeiten werden nach
+ Möglichkeit vorab in der App angekündigt.
+
`)}
+
+ ${sec('13. Änderungen dieser AGB', `
+
+ Änderungen der AGB werden per E-Mail und in der App angekündigt —
+ mindestens 4 Wochen vor Inkrafttreten. Widersprichst du den Änderungen nicht
+ innerhalb dieser Frist, gelten sie als angenommen. Dein Widerspruchsrecht und
+ das Recht zur außerordentlichen Kündigung bleiben unberührt.
+
`)}
+
+ ${sec('14. Anwendbares Recht', `
+
+ Es gilt ausschließlich deutsches Recht . Als Verbraucher hast du
+ deinen allgemeinen Gerichtsstand. Die EU-Plattform zur Online-Streitbeilegung
+ (ec.europa.eu/consumers/odr) wurde eingestellt. Wir nehmen nicht an alternativen
+ Streitbeilegungsverfahren teil (§ 36 VSBG).
+
`)}
+
+ ${sec('15. Kontakt', `
+
+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app
+
`)}
+
+
+ Stand: Mai 2026 · Version 2
+
+
+
+ `;
+ }
+
+ function refresh() {}
+
+ return { init, refresh };
+})();
diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js
index 425536f..893883f 100644
--- a/backend/static/js/pages/datenschutz.js
+++ b/backend/static/js/pages/datenschutz.js
@@ -32,6 +32,26 @@ window.Page_datenschutz = (() => {
E-Mail: hallo@banyaro.app
`)}
+ ${sec('Hosting & Infrastruktur', `
+
+ Die App wird auf einem eigenen Server (Synology DiskStation) in Deutschland betrieben.
+ Alle Daten werden ausschließlich auf diesem Server gespeichert und nicht an externe
+ Hoster übermittelt.
+
+
+ Für den E-Mail-Versand (Kontobestätigung, Benachrichtigungen, Rechnungen) nutzen wir
+ Brevo (Sendinblue SAS, 55 rue d'Amsterdam, 75008 Paris, Frankreich).
+ Brevo ist nach EU-Standardvertragsklauseln zertifiziert. Dabei werden E-Mail-Adresse
+ und Name übermittelt. Datenschutzinformationen:
+ brevo.com/de/legal/privacypolicy/ .
+
+
+ Für anonymisierte Nutzungsstatistiken betreiben wir Umami Analytics
+ auf unserem eigenen Server. Es werden keine personenbezogenen Daten oder IP-Adressen
+ gespeichert. Kein Tracking über Sitzungen hinweg.
+
`)}
+
${sec('Deine Daten gehören dir', `
Ban Yaro ist eine private Community-App. Dein Tagebuch , deine
@@ -70,6 +90,9 @@ window.Page_datenschutz = (() => {
Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft
widerrufen werden (Art. 7 Abs. 3 DSGVO) — einfach die entsprechende Funktion in den
Einstellungen deaktivieren oder die Browser-Freigabe entziehen.
+
+
+ Impressum und rechtliche Grundlage nach § 5 DDG (Digitale-Dienste-Gesetz).
`)}
${sec('Datenweitergabe', `
@@ -92,6 +115,13 @@ window.Page_datenschutz = (() => {
Du kannst Gespräche jederzeit selbst löschen.
`)}
+ ${sec('Moderation & Community', `
+
+ Zur Sicherstellung der Plattformqualität und Einhaltung unserer Nutzungsregeln können
+ Moderatoren und automatische Systeme Inhalte prüfen. Rechtsgrundlage ist
+ Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherer Plattform).
+
`)}
+
${sec('KI-Funktionen', `
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
@@ -127,6 +157,12 @@ window.Page_datenschutz = (() => {
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
findet nicht statt.
+
+
+ KI-Antworten können fehlerhaft oder unvollständig sein und dienen ausschließlich
+ allgemeinen Informationszwecken. Sie ersetzen keine tierärztliche oder fachliche
+ Beratung. Trotz EU-Standardvertragsklauseln besteht bei US-Anbietern ein Restrisiko,
+ dass US-Behörden auf übermittelte Daten zugreifen könnten.
`)}
${sec('Wetterdaten & Kartendienste', `
@@ -179,6 +215,16 @@ window.Page_datenschutz = (() => {
style="${S.a}">openrouteservice.org/privacy-policy
`)}
+ ${sec('Technische Speicherung', `
+
+ Ban Yaro verwendet technisch notwendige Speichermechanismen für den Betrieb der App:
+ Session-Tokens und Authentifizierungsdaten werden im Local Storage des Browsers
+ gespeichert. Ein Service Worker speichert App-Inhalte lokal für die Offline-Nutzung
+ (Cache). Push-Benachrichtigungs-Token werden für die Zustellung von Hinweisen benötigt.
+ Diese Speicherung ist für die Kernfunktion der App erforderlich; eine Einwilligung ist
+ nach § 25 Abs. 2 TTDSG nicht erforderlich. Es werden keine Tracking-Cookies eingesetzt.
+
`)}
+
${sec('Push-Benachrichtigungen', `
Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den
@@ -233,11 +279,28 @@ window.Page_datenschutz = (() => {
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
- Promenade 27, 91522 Ansbach
+ Promenade 18, 91522 Ansbach
+ poststelle@lda.bayern.de ·
www.lda.bayern.de
`)}
+ ${sec('Zahlungsdaten', `
+
+ Wenn du ein kostenpflichtiges Abonnement abschließt, verarbeiten wir folgende Daten:
+ Name, E-Mail-Adresse, Rechnungsadresse und den Zahlungseingang. Rechtsgrundlage ist
+ Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Rechnungsdaten werden gemäß
+ § 147 AO 10 Jahre aufbewahrt. Rechnungen werden per E-Mail mit
+ TLS-Verschlüsselung zugestellt.
+
+
+ Deine Zahlungsdaten (IBAN) werden nur für die Zuordnung des Zahlungseingangs intern
+ verwendet und nicht an Dritte weitergegeben. Die vertraglichen Bedingungen (Laufzeit,
+ Kündigung, Erstattung) findest du in unseren
+ AGB .
+
`)}
+
${sec('Speicherdauer', `
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
@@ -246,8 +309,14 @@ window.Page_datenschutz = (() => {
Server-Logs werden nach 30 Tagen rotiert.
`)}
+ ${sec('Mindestalter', `
+
+ Die Nutzung von Ban Yaro ist nur Personen ab 18 Jahren gestattet. Durch die
+ Registrierung bestätigt der Nutzer, das 18. Lebensjahr vollendet zu haben.
+
`)}
+
- Stand: Mai 2026 · Version 2
+ Stand: Mai 2026 · Version 3
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index f04bf67..d15c9b5 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -6,6 +6,8 @@
window.Page_diary = (() => {
+ const _CACHE_KEY = 'by_diary_cache';
+
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
@@ -324,6 +326,7 @@ window.Page_diary = (() => {
async function _load() {
const dog = _appState.activeDog;
if (!dog) return;
+ const cacheKey = _CACHE_KEY + '_' + dog.id;
try {
const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery;
@@ -331,6 +334,10 @@ window.Page_diary = (() => {
const batch = await API.diary.list(dog.id, params);
_entries = _entries.concat(batch);
+ if (_offset === 0 && !_searchQuery && !_filterMilestone) {
+ try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: batch })); } catch {}
+ }
+
// "Mehr laden" anzeigen wenn volle Page geladen wurde
const loadMore = _container.querySelector('#diary-load-more');
if (loadMore) {
@@ -339,7 +346,17 @@ window.Page_diary = (() => {
// Stats-Bar befüllen
_renderStatsBar();
- } catch (err) {
+ } catch {
+ try {
+ const raw = localStorage.getItem(cacheKey);
+ if (raw) {
+ const cached = JSON.parse(raw).data || [];
+ _entries = cached;
+ _renderStatsBar();
+ UI.toast.info('Offline — zeige zuletzt geladene Einträge.');
+ return;
+ }
+ } catch {}
UI.toast.error('Einträge konnten nicht geladen werden.');
}
}
@@ -1748,6 +1765,7 @@ window.Page_diary = (() => {
UI.toast.success('Eintrag gespeichert.');
} else {
const created = await API.diary.create(_appState.activeDog.id, payload);
+ if (created?._queued) { UI.modal.close(); return; }
if (_newFiles.length > 0) {
const { uploaded, exifGps } = await _uploadNewFiles(created.id);
created.media_items = uploaded;
diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js
index ffccb44..e05776d 100644
--- a/backend/static/js/pages/impressum.js
+++ b/backend/static/js/pages/impressum.js
@@ -24,12 +24,58 @@ window.Page_impressum = (() => {
Kontakt
-
+
E-Mail: hallo@banyaro.app
- Kontaktformular: Nachricht senden
+ Oder nutze das Formular — wir antworten in der Regel innerhalb von 24 Stunden.
+
+
+
+
+ Betreff *
+
+
+
+ Nachricht *
+
+
+
+
+ Nachricht senden
+
+
@@ -46,9 +92,6 @@ window.Page_impressum = (() => {
Streitschlichtung
- Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
- https://ec.europa.eu/consumers/odr .
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
@@ -61,20 +104,73 @@ window.Page_impressum = (() => {
Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit,
Vollständigkeit und Aktualität der Inhalte übernehmen wir keine Gewähr. Als
Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
- Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir
- keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer.
+ Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich
+ die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden
+ diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls
+ unverzüglich entfernt.
- Stand: April 2026
+ Stand: Mai 2026
`;
}
+ function _initContactForm(container) {
+ const form = container.querySelector('#contact-form');
+ const statusEl = container.querySelector('#cf-status');
+ const submitBtn = container.querySelector('#cf-submit');
+ if (!form) return;
+
+ form.addEventListener('submit', async e => {
+ e.preventDefault();
+ const name = container.querySelector('#cf-name').value.trim();
+ const email = container.querySelector('#cf-email').value.trim();
+ const subject = container.querySelector('#cf-subject').value.trim();
+ const message = container.querySelector('#cf-message').value.trim();
+
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Wird gesendet…';
+ statusEl.style.display = 'none';
+
+ try {
+ const res = await fetch('/api/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email, subject, message }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.detail || 'Fehler beim Senden.');
+ }
+ statusEl.style.display = 'block';
+ statusEl.style.background = 'var(--c-success-bg, #f0fdf4)';
+ statusEl.style.color = 'var(--c-success, #16a34a)';
+ statusEl.textContent = '✓ Nachricht gesendet — wir melden uns bald!';
+ form.reset();
+ } catch (err) {
+ statusEl.style.display = 'block';
+ statusEl.style.background = '#fef2f2';
+ statusEl.style.color = '#dc2626';
+ statusEl.textContent = err.message || 'Fehler beim Senden. Bitte versuche es später erneut.';
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Nachricht senden';
+ }
+ });
+ }
+
+ const _origInit = init;
+
function refresh() {}
- return { init, refresh };
+ return {
+ init(container) {
+ _origInit(container);
+ _initContactForm(container);
+ },
+ refresh
+ };
})();
diff --git a/backend/static/js/pages/laeufi.js b/backend/static/js/pages/laeufi.js
index de09370..e0fdb68 100644
--- a/backend/static/js/pages/laeufi.js
+++ b/backend/static/js/pages/laeufi.js
@@ -48,20 +48,20 @@ window.Page_laeufi = (() => {
`;
return `
- `;
@@ -69,14 +69,14 @@ window.Page_laeufi = (() => {
function _render() {
_container.innerHTML = `
-
+
${_privateHeader()}
-
+
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
-
`;
diff --git a/backend/static/js/pages/litters.js b/backend/static/js/pages/litters.js
index 8f86a86..f6f1883 100644
--- a/backend/static/js/pages/litters.js
+++ b/backend/static/js/pages/litters.js
@@ -31,13 +31,13 @@ window.Page_litters = (() => {
function _statusBadge(status) {
const map = {
- geplant: { label: 'Geplant', color: '#6B7280' },
- geboren: { label: 'Geboren', color: '#3B82F6' },
- verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
- abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
+ geplant: { label: 'Geplant', cls: 'badge-warning' },
+ geboren: { label: 'Geboren', cls: 'badge-primary' },
+ verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
+ abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
};
- const s = map[status] || { label: status, color: '#6B7280' };
- return `
${_esc(s.label)} `;
+ const s = map[status] || { label: status, cls: 'badge-muted' };
+ return `
${_esc(s.label)} `;
}
function _fmtDate(iso) {
@@ -54,12 +54,12 @@ window.Page_litters = (() => {
function _puppyStatusBadge(status) {
const map = {
- verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
- reserviert: { label: 'Reserviert', color: '#F59E0B' },
- abgegeben: { label: 'Abgegeben', color: '#6B7280' },
+ verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
+ reserviert: { label: 'Reserviert', cls: 'badge-warning' },
+ abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
};
- const s = map[status] || { label: status, color: '#9CA3AF' };
- return `
${_esc(s.label)} `;
+ const s = map[status] || { label: status, cls: 'badge-muted' };
+ return `
${_esc(s.label)} `;
}
// ----------------------------------------------------------
@@ -113,20 +113,20 @@ window.Page_litters = (() => {
`;
return `
- `;
diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js
index 086224e..6f8fe0c 100644
--- a/backend/static/js/pages/lost.js
+++ b/backend/static/js/pages/lost.js
@@ -5,17 +5,72 @@
window.Page_lost = (() => {
+ // ----------------------------------------------------------
+ // OFFLINE-CACHE
+ // ----------------------------------------------------------
+ const _CACHE_KEY = 'by_lost_cache';
+ const _PENDING_KEY = 'by_lost_pending';
+
+ function _getPending() {
+ try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
+ }
+ function _setPending(list) {
+ try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
+ }
+ function _addPending(data) {
+ const list = _getPending();
+ const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
+ created_at: new Date().toISOString() };
+ list.push(entry);
+ _setPending(list);
+ return entry;
+ }
+ async function _syncPending() {
+ if (!navigator.onLine) return;
+ const list = _getPending();
+ if (!list.length) return;
+ let ok = 0;
+ for (const item of [...list]) {
+ try {
+ const { id: _pid, _isPending, ...payload } = item;
+ await API.lost.report(payload);
+ _setPending(_getPending().filter(x => x.id !== item.id));
+ ok++;
+ } catch {}
+ }
+ if (ok > 0) { UI.toast.success(`${ok} Meldung(en) synchronisiert.`); _loadReports(); }
+ }
+ window.addEventListener('online', _syncPending);
+
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
- let _container = null;
- let _appState = null;
- let _map = null;
- let _markers = [];
- let _userMarker = null;
- let _reports = [];
- let _userPos = null;
- let _leafletLoaded = false;
+ let _container = null;
+ let _appState = null;
+ let _map = null;
+ let _markers = [];
+ let _userMarker = null;
+ let _reports = [];
+ let _userPos = null;
+ let _leafletLoaded = false;
+ let _stylesInjected = false;
+
+ function _injectStyles() {
+ if (_stylesInjected) return;
+ _stylesInjected = true;
+ const s = document.createElement('style');
+ s.textContent = `
+ @keyframes by-lost-pulse-r {
+ 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,.55), 0 2px 6px rgba(0,0,0,.3); }
+ 50% { box-shadow: 0 0 0 11px rgba(231,76,60,0), 0 2px 6px rgba(0,0,0,.3); }
+ }
+ @keyframes by-lost-pulse-p {
+ 0%,100% { box-shadow: 0 0 0 0 rgba(217,119,6,.55), 0 2px 6px rgba(0,0,0,.3); }
+ 50% { box-shadow: 0 0 0 11px rgba(217,119,6,0), 0 2px 6px rgba(0,0,0,.3); }
+ }
+ `;
+ document.head.appendChild(s);
+ }
// ----------------------------------------------------------
// INIT
@@ -113,6 +168,7 @@ window.Page_lost = (() => {
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
+ _injectStyles();
const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return;
@@ -180,7 +236,23 @@ window.Page_lost = (() => {
}
try {
- _reports = await API.lost.list(_userPos.lat, _userPos.lon, 25);
+ const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25);
+ try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
+
+ // Remove pending items already on the server (race: sync completed during fetch)
+ const rawPending = _getPending();
+ const dedupedPending = rawPending.filter(p =>
+ !fetched.some(f => f.name === p.name &&
+ Math.abs(f.lat - p.lat) < 0.0001 &&
+ Math.abs(f.lon - p.lon) < 0.0001)
+ );
+ if (dedupedPending.length < rawPending.length) _setPending(dedupedPending);
+
+ const pending = dedupedPending.map(p => ({
+ ...p,
+ distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon),
+ }));
+ _reports = [...pending, ...fetched];
_renderMarkers();
_renderHeld();
_renderList();
@@ -191,6 +263,31 @@ window.Page_lost = (() => {
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
}
} catch {
+ const offline_pending = _getPending().map(p => ({
+ ...p,
+ distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon),
+ }));
+ try {
+ const raw = localStorage.getItem(_CACHE_KEY);
+ if (raw) {
+ const cached = JSON.parse(raw).data || [];
+ _reports = [...offline_pending, ...cached];
+ _renderMarkers();
+ _renderHeld();
+ _renderList();
+ _updateBadge(_reports.length);
+ if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.';
+ return;
+ }
+ } catch {}
+ _reports = offline_pending;
+ if (offline_pending.length) {
+ _renderMarkers();
+ _renderHeld();
+ _renderList();
+ _updateBadge(_reports.length);
+ return;
+ }
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
@@ -204,20 +301,21 @@ window.Page_lost = (() => {
_markers = [];
_reports.forEach(r => {
+ const dotColor = r._isPending ? '#d97706' : '#e74c3c';
+ const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r';
const icon = L.divIcon({
className : '',
- html : `
🐕
`,
+ html : `
🐕
`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m !== undefined
- ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
+ ? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const marker = L.marker([r.lat, r.lon], { icon })
@@ -226,10 +324,11 @@ window.Page_lost = (() => {
🔍 ${_escape(r.name)}
${r.rasse ? _escape(r.rasse) + '
' : ''}
${distStr ? `
📍 ${distStr} entfernt ` : ''}
+ ${r._isPending ? '
⏳ Sync ausstehend ' : ''}
📅 ${_fmtDate(r.created_at)}
`);
- marker.on('click', () => _openDetail(r));
+ if (!r._isPending) marker.on('click', () => _openDetail(r));
_markers.push(marker);
});
}
@@ -271,10 +370,19 @@ window.Page_lost = (() => {
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
card.addEventListener('click', () => {
- const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
+ const id = card.dataset.lostId;
+ const r = _reports.find(x => String(x.id) === id && !x._isPending);
if (r) _openDetail(r);
});
});
+ listEl.querySelectorAll('.lost-discard-btn').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ const pid = btn.dataset.pendingId;
+ _setPending(_getPending().filter(x => x.id !== pid));
+ _loadReports();
+ });
+ });
listEl.querySelectorAll('.lost-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
@@ -332,14 +440,24 @@ window.Page_lost = (() => {
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
- ${_appState.user ? `
-
- Notiz
-
-
` : ''}
+ ${r._isPending
+ ? `
+ ⏳ Sync ausstehend
+
+ 🗑 Verwerfen
+
+
`
+ : (_appState.user ? `
+
+ Notiz
+
+
` : '')}
@@ -350,6 +468,7 @@ window.Page_lost = (() => {
// DETAIL-MODAL
// ----------------------------------------------------------
function _openDetail(r) {
+ if (r._isPending) return; // Pending-Einträge haben keine Server-ID
const isOwn = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin';
const distStr = r.distanz_m !== undefined
@@ -632,7 +751,23 @@ window.Page_lost = (() => {
client_time : API.clientNow(),
};
- const created = await API.lost.report(payload);
+ let created;
+ try {
+ created = await API.lost.report(payload);
+ } catch (netErr) {
+ // Netzwerkfehler (TypeError = fetch failed) → offline speichern
+ if (netErr instanceof TypeError || !navigator.onLine) {
+ const pending = _addPending(payload);
+ pending.distanz_m = _userPos
+ ? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon))
+ : 0;
+ UI.modal.close();
+ UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.');
+ _loadReports();
+ return;
+ }
+ throw netErr; // API-Fehler (z.B. 422) → weitergeben
+ }
// Foto hochladen
if (photoInput?.files[0]) {
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index bc65d98..36dbc46 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -11,9 +11,11 @@ window.Page_map = (() => {
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
- let _weatherLoaded = false;
- let _placingMarker = false;
- let _tempMarker = null;
+ let _weatherLoaded = false;
+ let _placingMarker = false;
+ let _tempMarker = null;
+ let _tileLayer = null;
+ let _themeObserver = null;
// Standort-Tracking
let _locationMarker = null;
@@ -58,7 +60,8 @@ window.Page_map = (() => {
zuechter: [],
};
- const VISIBLE_KEY = 'by_map_visible_v1';
+ const VISIBLE_KEY = 'by_map_visible_v1';
+ const _MAP_POI_KEY = 'by_map_pois_cache';
let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar
@@ -75,7 +78,7 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
- restaurant: { icon: ' ', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 },
+ restaurant: { icon: ' ', label: 'Café & Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: ' ', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: ' ', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: ' ', label: 'Kotbeutel', color: '#84A98C', z: 5 },
@@ -92,7 +95,7 @@ window.Page_map = (() => {
treffpunkt: { icon: ' ', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: ' ', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: ' ', label: 'Züchter', color: '#7C3AED', z: 50 },
- hotel: { icon: ' ', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
+ hotel: { icon: ' ', label: 'Hotel', color: '#0369a1', z: 20 },
};
// Frontend-Layer → Backend-Typ Mapping
@@ -180,6 +183,7 @@ window.Page_map = (() => {
+ Filter
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
@@ -214,7 +218,7 @@ window.Page_map = (() => {
-
+
·
@@ -526,7 +530,16 @@ window.Page_map = (() => {
if (!_userPos) {
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
}
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
+ _tileLayer = _buildTileLayer();
+ _tileLayer.addTo(_map);
+
+ // Sofort Dark-Filter anwenden wenn nötig (nach Tile-Load)
+ _tileLayer.on('load', _applyTileTheme);
+ _applyTileTheme();
+ // Theme-Wechsel → Filter aktualisieren
+ _themeObserver = new MutationObserver(() => _applyTileTheme());
+ _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _applyTileTheme);
setTimeout(() => _map.invalidateSize(), 100);
setTimeout(() => _map.invalidateSize(), 600);
@@ -600,7 +613,7 @@ window.Page_map = (() => {
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
- border:2px solid rgba(255,255,255,0.8)">${n} `,
+ border:2px solid rgba(52,68,36,0.65)">${n}`,
iconSize: [36, 36], iconAnchor: [18, 18],
});
},
@@ -612,14 +625,34 @@ window.Page_map = (() => {
return _clusterGroups[layerKey];
}
+ function _isDarkMode() {
+ const t = document.documentElement.getAttribute('data-theme');
+ if (t === 'dark') return true;
+ if (t === 'light') return false;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+
+ const _OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+ const _DARK_FILTER = 'invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)';
+
+ function _buildTileLayer() {
+ return L.tileLayer(_OSM_URL, { maxZoom: 19 });
+ }
+
+ function _applyTileTheme() {
+ if (!_map) return;
+ const tilePaneEl = _map.getPane('tilePane');
+ if (tilePaneEl) tilePaneEl.style.filter = _isDarkMode() ? _DARK_FILTER : '';
+ }
+
function _updateZoomDisplay() {
if (!_map) return;
const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info');
if (!el) return;
- if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; }
- else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
- else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
+ if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; }
+ else if (z < 14) { el.textContent = `Z${z}`; el.title = 'Ab Z14: alle Layer'; el.style.opacity = '0.7'; }
+ else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; }
}
function _setOsmStatus(text, pct = null) {
@@ -937,7 +970,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
- border:2px solid rgba(255,255,255,0.7)">${t.icon}`,
+ border:2px solid rgba(52,68,36,0.55)">${t.icon}`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
@@ -1217,9 +1250,41 @@ window.Page_map = (() => {
API.breeder.mapMarkers(),
]);
- if (places.status === 'fulfilled') _addPlaces(places.value);
- if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
- if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
+ const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected');
+ if (allFailed) {
+ try {
+ const raw = localStorage.getItem(_MAP_POI_KEY);
+ if (raw) {
+ const cached = JSON.parse(raw);
+ _addPlaces(cached.places || []);
+ _addPoison(cached.poison || []);
+ _addBreeders(cached.breeders || []);
+ UI.toast.info('Offline — Karte zeigt gecachte Kacheln. POI-Daten eventuell veraltet.');
+ _scheduleOsmLoad();
+ return;
+ }
+ } catch {}
+ }
+
+ const placesVal = places.status === 'fulfilled' ? places.value : [];
+ const poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : [];
+ const breederVal = breederList.status === 'fulfilled' ? breederList.value : [];
+
+ if (places.status === 'fulfilled') _addPlaces(placesVal);
+ if (poisonList.status === 'fulfilled') _addPoison(poisonVal);
+ if (breederList.status === 'fulfilled') _addBreeders(breederVal);
+
+ if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') {
+ try {
+ localStorage.setItem(_MAP_POI_KEY, JSON.stringify({
+ ts: Date.now(),
+ places: placesVal,
+ poison: poisonVal,
+ breeders: breederVal,
+ }));
+ } catch {}
+ }
+
_scheduleOsmLoad();
}
@@ -1270,7 +1335,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
- border:2px solid rgba(255,255,255,0.7)">${t.icon}`,
+ border:2px solid rgba(52,68,36,0.55)">${t.icon}`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
@@ -1314,7 +1379,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
- border:2px solid rgba(255,255,255,0.7)">${t.icon}`,
+ border:2px solid rgba(52,68,36,0.55)">${t.icon}`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js
index 68e7c50..150fddc 100644
--- a/backend/static/js/pages/poison.js
+++ b/backend/static/js/pages/poison.js
@@ -5,6 +5,8 @@
window.Page_poison = (() => {
+ const _CACHE_KEY = 'by_poison_cache';
+
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
@@ -171,6 +173,7 @@ window.Page_poison = (() => {
try {
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
+ try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
@@ -180,6 +183,18 @@ window.Page_poison = (() => {
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
}
} catch {
+ try {
+ const raw = localStorage.getItem(_CACHE_KEY);
+ if (raw) {
+ _reports = JSON.parse(raw).data || [];
+ _renderMarkers();
+ _renderList();
+ _updateBadge(_reports.length);
+ if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`;
+ UI.toast.info('Offline — zeige zuletzt geladene Daten.');
+ return;
+ }
+ } catch {}
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
@@ -528,6 +543,12 @@ window.Page_poison = (() => {
const created = await API.poison.report(payload);
+ // SW hat Request in Queue gelegt (offline)
+ if (created?._queued) {
+ _showPoisonThanks(true);
+ return;
+ }
+
// Foto hochladen
if (photoInput?.files[0]) {
try {
@@ -540,8 +561,7 @@ window.Page_poison = (() => {
}
}
- // Distanz client-seitig berechnen (für sofortige Anzeige)
- // _userPos aktualisieren falls Picker neuen Standort geliefert hat
+ // Distanz client-seitig berechnen
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
@@ -553,12 +573,45 @@ window.Page_poison = (() => {
_updateBadge(_reports.length);
App.checkNearbyAlerts();
App.callModule('map', 'refresh');
- UI.toast.success('Giftköder gemeldet! Danke für die Warnung.');
- UI.modal.close();
+ _showPoisonThanks(false);
});
});
}
+ // ----------------------------------------------------------
+ // DANKE-OVERLAY nach Giftköder-Meldung
+ // ----------------------------------------------------------
+ function _showPoisonThanks(isQueued) {
+ const offlineNote = isQueued
+ ? `
+
+ Wird synchronisiert sobald du wieder online bist.
+
`
+ : '';
+ UI.modal.open({
+ title: 'Danke für deine Meldung!',
+ body: `
+
+
+
+
+
+ Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
+
+
+
+ Vielen Dank, dass du die Community schützt!
+
+ ${offlineNote}
+
+ `,
+ footer: `OK `,
+ });
+ document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close);
+ setTimeout(() => UI.modal.close(), 5000);
+ }
+
// ----------------------------------------------------------
// BADGE (Sidebar + Bottom-Nav)
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js
index 80cffa9..b09e4fb 100644
--- a/backend/static/js/pages/routes.js
+++ b/backend/static/js/pages/routes.js
@@ -5,6 +5,40 @@
window.Page_routes = (() => {
+ const _CACHE_KEY = 'by_routes_cache';
+ const _PENDING_KEY = 'by_routes_pending';
+
+ function _getPending() {
+ try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
+ }
+ function _setPending(list) {
+ try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
+ }
+ function _addPending(data) {
+ const list = _getPending();
+ const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
+ created_at: new Date().toISOString(), user_id: null };
+ list.push(entry);
+ _setPending(list);
+ return entry;
+ }
+ async function _syncPending() {
+ if (!navigator.onLine) return;
+ const list = _getPending();
+ if (!list.length) return;
+ let ok = 0;
+ for (const r of [...list]) {
+ try {
+ const { id: _pid, _isPending, ...payload } = r;
+ await API.routes.create(payload);
+ _setPending(_getPending().filter(x => x.id !== r.id));
+ ok++;
+ } catch {}
+ }
+ if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); }
+ }
+ window.addEventListener('online', _syncPending);
+
let _container = null;
let _appState = null;
let _data = [];
@@ -634,7 +668,8 @@ window.Page_routes = (() => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return;
- await UI.loadLeaflet?.() ?? Promise.resolve();
+ try { await (UI.loadLeaflet?.() ?? Promise.resolve()); }
+ catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); }
const ovl = document.createElement('div');
ovl.id = 'rk-rec-ovl';
@@ -691,24 +726,37 @@ window.Page_routes = (() => {
document.body.appendChild(ovl);
_recOvl = ovl;
- const pos = _userPos || { lat: 48.1, lon: 11.5 };
- _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
- .setView([pos.lat, pos.lon], 15);
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
- _recLocMarker = L.circleMarker([pos.lat, pos.lon], {
- radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
- }).addTo(_recMap);
+ // Listener sofort nach DOM-Einfügen — nicht nach async-Operationen
+ ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
+ ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
- // Get accurate position
+ // Map-Setup: Leaflet könnte offline fehlen → alles in try/catch
+ const pos = _userPos || { lat: 48.1, lon: 11.5 };
+ try {
+ if (!window.L) throw new Error('Leaflet not loaded');
+ _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
+ .setView([pos.lat, pos.lon], 15);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
+ _recLocMarker = L.circleMarker([pos.lat, pos.lon], {
+ radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
+ }).addTo(_recMap);
+ } catch {
+ const mapWrap = ovl.querySelector('#rk-rec-map-wrap');
+ if (mapWrap) mapWrap.innerHTML =
+ `
+ 📡
+ Karte offline nicht verfügbar — GPS läuft trotzdem
+
`;
+ }
+
+ // Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS)
try {
const p = await API.getLocation();
_userPos = p;
- _recMap.setView([p.lat, p.lon], 16);
- _recLocMarker.setLatLng([p.lat, p.lon]);
+ _recMap?.setView([p.lat, p.lon], 16);
+ _recLocMarker?.setLatLng([p.lat, p.lon]);
} catch {}
-
- ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
- ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
}
async function _startRecInOvl() {
@@ -732,6 +780,7 @@ window.Page_routes = (() => {
setTimeout(() => banner.remove(), 9000);
}
+
const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = `
@@ -773,7 +822,9 @@ window.Page_routes = (() => {
btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = '';
- _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
+ if (_recMap && window.L) {
+ _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
+ }
await _recAcquireWakeLock();
document.addEventListener('visibilitychange', _recOnVisibility);
@@ -788,9 +839,9 @@ window.Page_routes = (() => {
_recDistKm += d;
}
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
- _recPolyline.addLatLng([lat, lon]);
- _recLocMarker.setLatLng([lat, lon]);
- if (_recTrack.length === 1) _recMap.setView([lat, lon], 16);
+ _recPolyline?.addLatLng([lat, lon]);
+ _recLocMarker?.setLatLng([lat, lon]);
+ if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
_updateRecStats();
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
@@ -1011,7 +1062,7 @@ window.Page_routes = (() => {
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
- const saved = await API.routes.create({
+ const payload = {
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
@@ -1024,7 +1075,15 @@ window.Page_routes = (() => {
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(),
- });
+ };
+ if (!navigator.onLine) {
+ _addPending(payload);
+ UI.modal.close();
+ UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`);
+ _loadData();
+ return;
+ }
+ const saved = await API.routes.create(payload);
UI.modal.close();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
_loadData();
@@ -1209,20 +1268,36 @@ window.Page_routes = (() => {
// Daten
// ----------------------------------------------------------
async function _loadData() {
+ const _merge = (online) => {
+ const pending = _getPending();
+ if (pending.length) _data = [...pending, ..._data];
+ if (_appState.user && _browseMode === 'mine')
+ document.getElementById('rk-mine-group')?.style.setProperty('display', '');
+ if (_browseMode === 'discover' && _userPos)
+ document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
+ if (!online && pending.length)
+ UI.toast.info('Offline — ' + pending.length + ' Route(n) warten auf Sync.');
+ _applyFilter();
+ };
try {
_data = await API.routes.list();
- // "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
- if (_appState.user && _browseMode === 'mine') {
- document.getElementById('rk-mine-group')?.style.setProperty('display', '');
- }
- // Standort-abhängiger Filter im Entdecken-Modus
- if (_browseMode === 'discover' && _userPos) {
- document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
- }
- _applyFilter();
- } catch (err) {
+ try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {}
+ _merge(true);
+ } catch {
+ try {
+ const raw = localStorage.getItem(_CACHE_KEY);
+ if (raw) {
+ _data = JSON.parse(raw).data || [];
+ UI.toast.info('Offline — zeige zuletzt geladene Routen.');
+ _merge(false);
+ return;
+ }
+ } catch {}
+ // Nur Pending-Routen zeigen wenn gar kein Cache
+ _data = _getPending();
+ if (_data.length) { _merge(false); return; }
document.getElementById('rk-grid').innerHTML =
- `Fehler: ${UI.escape(err.message)}
`;
+ `Offline — noch keine Routen gecacht.
`;
}
}
@@ -1369,10 +1444,13 @@ window.Page_routes = (() => {
: '';
return `
-
+
${previewContent}
${authorLine}
+ ${r._isPending ? `
+ ${UI.icon('cloud-arrow-up')} Sync ausstehend
` : ''}
${UI.escape(r.name)}
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js
index 2d23a96..16adf63 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -307,6 +307,36 @@ window.Page_settings = (() => {
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail.
+ ${!_appState.user?.billing_address ? `
+
+ 💡 Tipp: Trag deine Rechnungsadresse im Profil ein — dann können wir die Rechnung vollständig ausstellen.
+
` : ''}
+
+
+
+
+ Ich habe die AGB gelesen und stimme ihnen zu.
+
+
+
+
+
+
+
+ Ich stimme zu, dass mein Zugang sofort nach Freischaltung beginnt, und bestätige,
+ dass ich damit mein 14-tägiges Widerrufsrecht verliere (§ 356 Abs. 4 BGB).
+
+
+
${breederForm}
`,
footer: `
@@ -324,6 +354,17 @@ window.Page_settings = (() => {
`
});
+ const agbBox = document.getElementById('agb-checkbox');
+ const widerrufBox = document.getElementById('widerruf-checkbox');
+ const sendBtn = document.getElementById('upgrade-request-send-btn');
+ if (sendBtn) sendBtn.disabled = true;
+
+ const _checkBtns = () => {
+ if (sendBtn) sendBtn.disabled = !(agbBox?.checked && widerrufBox?.checked);
+ };
+ agbBox?.addEventListener('change', _checkBtns);
+ widerrufBox?.addEventListener('change', _checkBtns);
+
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('upgrade-request-send-btn');
if (!btn) return;
@@ -357,7 +398,8 @@ window.Page_settings = (() => {
}
try {
- const res = await API.auth.upgradeRequest(tier);
+ const widerrufAt = new Date().toLocaleString('de-DE');
+ const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
UI.modal.close();
if (res.already) {
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
@@ -1135,6 +1177,22 @@ window.Page_settings = (() => {
value="${_esc(u.social_link || '')}"
style="${inputStyle}">
+
+
Rechnungsadresse
+
Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.
+
${_esc(u.billing_address || '')}
+
+
+
Dein Geburtstag (optional)
+
+
Wird nur für Geburtstagsgrüße in der App verwendet.
+
Profil-Sichtbarkeit
${sichtbarkeitOpts}
@@ -1161,8 +1219,11 @@ window.Page_settings = (() => {
erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
+ billing_address: fd.billing_address || '',
+ geburtstag: fd.geburtstag || '',
});
Object.assign(_appState.user, updated);
+ window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
UI.modal.close?.();
UI.toast.success('Profil gespeichert.');
_render();
diff --git a/backend/static/js/pages/social.js b/backend/static/js/pages/social.js
index 010d493..d1af781 100644
--- a/backend/static/js/pages/social.js
+++ b/backend/static/js/pages/social.js
@@ -43,6 +43,7 @@ window.Page_social = (() => {
function _render() {
const lvlBar = _stats ? _levelBar(_stats) : '';
_el.innerHTML = `
+
@@ -71,7 +72,8 @@ window.Page_social = (() => {
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
${l}`).join('')}
-
`;
+
+
`;
_el.querySelectorAll('.sm-tab').forEach(b =>
b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); }));
diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js
index c6ba308..9410e0a 100644
--- a/backend/static/js/pages/uebungen.js
+++ b/backend/static/js/pages/uebungen.js
@@ -56,6 +56,7 @@ window.Page_uebungen = (() => {
{ id: 'welpe-basics', label: 'Welpe Basics' },
{ id: 'grundlagen', label: 'Trainingsgrundlagen' },
{ id: 'ki-trainer', label: 'KI-Trainer' },
+ { id: 'verlauf', label: 'Protokoll' },
];
// ----------------------------------------------------------
@@ -541,11 +542,15 @@ window.Page_uebungen = (() => {
_renderContent();
}
function onDogChange() {
- _statsData = null;
- _badgesData = null;
- _progressCache = {};
+ _statsData = null;
+ _badgesData = null;
+ _progressCache = {};
_progressLoaded = false;
- _exerciseStats = {};
+ _exerciseStats = {};
+ _verlaufSessions = [];
+ _verlaufOffset = 0;
+ _verlaufLoading = false;
+ _verlaufView = 'datum';
_render();
_loadStatsAndBadges();
_loadVirtualTrainer();
@@ -558,34 +563,21 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
${UI.dogChip(_appState)}
-
-
+
+
+
+
+
+
+ Stand erfassen
+
${_renderTabs()}
@@ -980,10 +972,11 @@ window.Page_uebungen = (() => {
const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
+ const isVerlauf = _activeTab === 'verlauf';
const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
- if (quickWrap) quickWrap.style.display = showIf(isExerciseTab);
+ if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none';
const trainerEl = _container.querySelector('#ueb-trainer');
const suggestEl = _container.querySelector('#ueb-suggestions');
const bannerEl = _container.querySelector('#ueb-stats-banner');
@@ -1011,6 +1004,17 @@ window.Page_uebungen = (() => {
break;
}
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
+ case 'verlauf': {
+ if (_verlaufSessions.length > 0) {
+ el.innerHTML = `
`;
+ _renderVerlaufList(el.querySelector('#verlauf-list'));
+ } else {
+ el.innerHTML = _renderVerlaufShell();
+ _loadVerlauf();
+ }
+ _bindVerlaufToggle();
+ break;
+ }
case 'ki-trainer':
if (!App.hasPro(_appState?.user)) {
el.innerHTML = `
@@ -1647,18 +1651,6 @@ window.Page_uebungen = (() => {
background:var(--c-surface);color:var(--c-text);line-height:1.5">
-
-
-
-
- 📖 Als Meilenstein ins Tagebuch eintragen
-
-
-
@@ -1714,7 +1706,6 @@ window.Page_uebungen = (() => {
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)';
- _checkMilestoneVisibility();
});
});
@@ -1738,17 +1729,9 @@ window.Page_uebungen = (() => {
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
});
- _checkMilestoneVisibility();
});
});
- function _checkMilestoneVisibility() {
- const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
- if (!wrap) return;
- const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
- wrap.hidden = !show;
- }
-
// Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId();
@@ -1761,20 +1744,17 @@ window.Page_uebungen = (() => {
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10);
- const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
- overlay.querySelector('#ueb-log-milestone').checked;
const body = {
- dog_id: dogId,
- exercise_id: exerciseId,
- exercise_name: exerciseName,
- datum: today,
- wiederholungen: wiederholungen,
- erfolgsquote: erfolgsquote,
- hund_stimmung: stimmung || null,
- zufriedenheit: zufriedenheit || null,
- notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
- tagebuch_eintrag: tagebuch,
+ dog_id: dogId,
+ exercise_id: exerciseId,
+ exercise_name: exerciseName,
+ datum: today,
+ wiederholungen: wiederholungen,
+ erfolgsquote: erfolgsquote,
+ hund_stimmung: stimmung || null,
+ zufriedenheit: zufriedenheit || null,
+ notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
};
try {
@@ -1806,12 +1786,6 @@ window.Page_uebungen = (() => {
});
}
- if (resp.diary_entry_id) {
- setTimeout(() => {
- UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
- }, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
- }
-
// Stats-Banner + Trainer aktualisieren
_statsData = null;
_loadStatsAndBadges();
@@ -1995,6 +1969,325 @@ window.Page_uebungen = (() => {
});
}
+ // ----------------------------------------------------------
+ // TRAININGSPROTOKOLL (Verlauf-Tab)
+ // ----------------------------------------------------------
+ let _verlaufSessions = [];
+ let _verlaufOffset = 0;
+ let _verlaufHasMore = false;
+ let _verlaufLoading = false;
+ let _verlaufView = 'datum'; // 'datum' | 'uebung'
+ const _VERLAUF_LIMIT = 30;
+
+ const _ERFOLG_EMOJI = { 0: '😓', 25: '😐', 50: '🙂', 75: '😊', 100: '🎉' };
+ const _STIMMUNG_EMOJI = { aufmerksam: '🎯', müde: '😴', abgelenkt: '🌪️', super: '⚡' };
+
+ function _renderVerlaufShell() {
+ const dogId = _dogId();
+ if (!dogId) {
+ return `
+
Wähle einen Hund aus um das Protokoll zu sehen.
+
`;
+ }
+ return `
+ ${_verlaufToggleHtml()}
+
+
`;
+ }
+
+ function _verlaufToggleHtml() {
+ const btnBase = `padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
+ font-size:var(--text-xs);font-weight:var(--weight-semibold);cursor:pointer;
+ border:1px solid var(--c-border);transition:all .15s`;
+ const active = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`;
+ const inactive = `background:var(--c-surface-2);color:var(--c-text-secondary)`;
+ return `
+
+
+ Nach Datum
+
+
+ Nach Übung
+
+
`;
+ }
+
+ 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
+ ? `
+ Weitere laden
+ `
+ : '';
+
+ 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 `
+
+
+
+
+
+ ${ex.avg}%
+
+ ${TREND_ICON[ex.trend]}
+
+
+
+
+
+ ${_esc(ex.name)}
+
+
+ ${ex.sessions.length} Einheit${ex.sessions.length !== 1 ? 'en' : ''}
+ ${ex.topCount ? ' · ' + ex.topCount + '× TOP' : ''}
+ · ${_esc(lastLabel)}
+
+
+
+
+
+
+
+
+
+ ${sessionRows}
+
+
`;
+ }).join('');
+
+ const hint = _verlaufHasMore
+ ? `
+ Zeigt die letzten ${_verlaufSessions.length} Einheiten — ältere nicht berücksichtigt.
+
`
+ : '';
+
+ el.innerHTML = cards + hint;
+
+ // Akkordeon-Binding
+ el.querySelectorAll('.verlauf-ex-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const uid = btn.dataset.uid;
+ const body = document.getElementById(uid);
+ const chev = el.querySelector(`.verlauf-ex-chevron[data-uid="${uid}"]`);
+ const isOpen = !body.hidden;
+ body.hidden = isOpen;
+ if (chev) chev.style.transform = isOpen ? '' : 'rotate(180deg)';
+ });
+ });
+ }
+
// ----------------------------------------------------------
// TRAININGSGRUNDLAGEN
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js
index d925a29..e0c6c40 100644
--- a/backend/static/js/pages/walks.js
+++ b/backend/static/js/pages/walks.js
@@ -5,6 +5,45 @@
window.Page_walks = (() => {
+ // ----------------------------------------------------------
+ // OFFLINE-CACHE
+ // ----------------------------------------------------------
+ const _CACHE_KEY = 'by_walks_cache';
+ const _PENDING_KEY = 'by_walks_pending';
+
+ function _getPending() {
+ try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
+ }
+ function _setPending(list) {
+ try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
+ }
+ function _addPending(data) {
+ const list = _getPending();
+ const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
+ created_at: new Date().toISOString(),
+ teilnehmer_count: 1, max_teilnehmer: data.max_teilnehmer || 10,
+ status: 'open' };
+ list.push(entry);
+ _setPending(list);
+ return entry;
+ }
+ async function _syncPending() {
+ if (!navigator.onLine) return;
+ const list = _getPending();
+ if (!list.length) return;
+ let ok = 0;
+ for (const item of [...list]) {
+ try {
+ const { id: _pid, _isPending, created_at: _ca, teilnehmer_count: _tc, status: _st, ...payload } = item;
+ await API.walks.create(payload);
+ _setPending(_getPending().filter(x => x.id !== item.id));
+ ok++;
+ } catch {}
+ }
+ if (ok > 0) { UI.toast.success(`${ok} Treffen synchronisiert.`); _loadData(); }
+ }
+ window.addEventListener('online', _syncPending);
+
let _container = null;
let _appState = null;
let _data = [];
@@ -195,14 +234,16 @@ window.Page_walks = (() => {
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
+ const pending = _getPending();
try {
- _data = await API.walks.list(
+ const fetched = await API.walks.list(
_userPos?.lat ?? null,
_userPos?.lon ?? null
);
+ try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
+ _data = [...pending, ...fetched];
_renderList();
_renderMarkers();
- // Desktop: Karte direkt initialisieren (beide Panels sichtbar)
if (window.innerWidth >= 1024) {
UI.loadLeaflet().then(() => {
_initMap();
@@ -210,8 +251,20 @@ window.Page_walks = (() => {
setTimeout(() => _map?.invalidateSize(), 400);
});
}
- } catch (err) {
- UI.toast.error(err.message || 'Fehler beim Laden.');
+ } catch {
+ try {
+ const raw = localStorage.getItem(_CACHE_KEY);
+ if (raw) {
+ _data = [...pending, ...(JSON.parse(raw).data || [])];
+ _renderList();
+ _renderMarkers();
+ UI.toast.info('Offline — zeige zuletzt geladene Treffen.');
+ return;
+ }
+ } catch {}
+ _data = pending;
+ if (pending.length) { _renderList(); _renderMarkers(); return; }
+ UI.toast.error('Treffen konnten nicht geladen werden.');
}
}
@@ -291,6 +344,7 @@ window.Page_walks = (() => {
${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}
${isOwn ? '
Mein Treffen ' : ''}
+ ${w._isPending ? `
⏳ Sync ausstehend ` : ''}
@@ -1128,15 +1182,30 @@ window.Page_walks = (() => {
const idx = _data.findIndex(w => w.id === walk.id);
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
UI.toast.success('Treffen aktualisiert.');
+ UI.modal.close();
+ _renderList();
+ _renderMarkers();
} else {
- const created = await API.walks.create(payload);
+ let created;
+ try {
+ created = await API.walks.create(payload);
+ } catch (netErr) {
+ if (netErr instanceof TypeError || !navigator.onLine) {
+ _addPending(payload);
+ UI.modal.close();
+ UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.');
+ _loadData();
+ return;
+ }
+ throw netErr;
+ }
+ if (created?._queued) { UI.modal.close(); _loadData(); return; }
_data.unshift({ ...created, teilnehmer_count: 0 });
UI.toast.success('Treffen geplant! 🎉');
+ UI.modal.close();
+ _renderList();
+ _renderMarkers();
}
-
- UI.modal.close();
- _renderList();
- _renderMarkers();
});
});
}
diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js
index 9e60fb0..256f499 100644
--- a/backend/static/js/pages/welcome.js
+++ b/backend/static/js/pages/welcome.js
@@ -167,6 +167,8 @@ window.Page_welcome = (() => {
Impressum
·
Datenschutz
+ ·
+
AGB
`;
diff --git a/backend/static/js/pages/zuchthunde.js b/backend/static/js/pages/zuchthunde.js
index 0d4d96f..9798b02 100644
--- a/backend/static/js/pages/zuchthunde.js
+++ b/backend/static/js/pages/zuchthunde.js
@@ -115,20 +115,20 @@ window.Page_zuchthunde = (() => {
`;
return `
- `;
diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js
index a0b69b1..9da043f 100644
--- a/backend/static/js/ui.js
+++ b/backend/static/js/ui.js
@@ -83,13 +83,19 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');
- // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
+ // Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen
let _vvCleanup = null;
const vv = window.visualViewport;
+ const modal = overlay.querySelector('.modal');
if (vv) {
const adjust = () => {
- const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
- overlay.style.paddingBottom = (kb + 16) + 'px';
+ const visible = vv.height;
+ const offset = vv.offsetTop;
+ const kb = Math.max(0, window.innerHeight - visible - offset);
+ // Overlay-Padding damit Modal nach oben rückt
+ overlay.style.paddingBottom = (kb + 8) + 'px';
+ // Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt
+ if (modal) modal.style.maxHeight = (visible - 24) + 'px';
};
vv.addEventListener('resize', adjust);
vv.addEventListener('scroll', adjust);
@@ -97,19 +103,37 @@ const UI = (() => {
vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = '';
+ if (modal) modal.style.maxHeight = '';
};
}
- _current = { overlay, onClose, _vvCleanup };
+ // Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView
+ // arbeitet nicht zuverlässig in overflow-Containern)
+ const _onFocusin = e => {
+ const el = e.target;
+ if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return;
+ setTimeout(() => {
+ const body = el.closest('.modal-body');
+ if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
+ const elBottom = el.getBoundingClientRect().bottom;
+ const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight;
+ const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur
+ if (gap > 0) body.scrollTop += gap;
+ }, 380);
+ };
+ overlay.addEventListener('focusin', _onFocusin);
+
+ _current = { overlay, onClose, _vvCleanup, _onFocusin };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
- const { onClose, _vvCleanup } = _current;
+ const { onClose, _vvCleanup, _onFocusin } = _current;
onClose?.();
_vvCleanup?.();
+ if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;
diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js
index b250a29..7459eeb 100644
--- a/backend/static/js/worlds.js
+++ b/backend/static/js/worlds.js
@@ -598,13 +598,21 @@ window.Worlds = (() => {
let _cfgCache = null;
function _mergeDefaults(cfg) {
- // Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen
- const result = JSON.parse(JSON.stringify(cfg));
+ const result = JSON.parse(JSON.stringify(cfg));
+ const hidden = new Set(result.hidden || []);
+ // Chips die bereits einer Welt zugewiesen sind, nicht nochmal einfügen
+ const allAssigned = new Set([
+ ...(result.jetzt || []), ...(result.hund || []), ...(result.welt || []),
+ ]);
for (const world of ['jetzt', 'hund', 'welt']) {
const def = _DEFAULT_CONFIG[world] || [];
const saved = result[world] || [];
for (const page of def) {
- if (!saved.includes(page)) saved.push(page);
+ // Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet
+ if (!allAssigned.has(page) && !hidden.has(page)) {
+ saved.push(page);
+ allAssigned.add(page);
+ }
}
result[world] = saved;
}
@@ -637,6 +645,11 @@ window.Worlds = (() => {
}
function _saveConfig(cfg) {
+ // Bewusst ausgeblendete Chips tracken: Default-Chips die keiner Welt zugewiesen sind
+ const allAssigned = new Set([...(cfg.jetzt||[]), ...(cfg.hund||[]), ...(cfg.welt||[])]);
+ const allDefault = [..._DEFAULT_CONFIG.jetzt, ..._DEFAULT_CONFIG.hund, ..._DEFAULT_CONFIG.welt];
+ cfg.hidden = allDefault.filter(p => !allAssigned.has(p));
+
_cfgCache = cfg;
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
if (_state?.user) {
@@ -710,15 +723,18 @@ window.Worlds = (() => {
const bottomNav = document.getElementById('bottom-nav');
if (bottomNav) bottomNav.style.display = 'none';
+ const _isDesktop = window.innerWidth >= 768;
const ov = document.createElement('div');
ov.id = 'wc-overlay';
- ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
+ ov.style.cssText = _isDesktop
+ ? 'position:fixed;inset:0;z-index:500;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)'
+ : 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
document.body.appendChild(ov);
const _removeDragListeners = () => {
- document.removeEventListener('touchmove', _onDragMove);
- document.removeEventListener('touchend', _onDragEnd);
- document.removeEventListener('touchcancel', _onDragEnd);
+ document.removeEventListener('pointermove', _onDragMove);
+ document.removeEventListener('pointerup', _onDragEnd);
+ document.removeEventListener('pointercancel', _onDragEnd);
};
const _cancelDrag = () => {
if (!_drag) return;
@@ -736,11 +752,14 @@ window.Worlds = (() => {
};
function _render() {
+ const _sheetStyle = _isDesktop
+ ? 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:20px;width:90%;max-width:1100px;max-height:88vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 20px'
+ : 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)';
+ const _gridCols = _isDesktop ? 'repeat(auto-fill,minmax(120px,1fr))' : 'repeat(4,1fr)';
+ const _chipH = _isDesktop ? '64px' : '80px';
ov.innerHTML = `
-
-
+ ${!_isDesktop ? '
' : ''}
+
(${chips.length})` : ''}
${chips.map(c => `
${!c.pinned ? `
@@ -855,29 +874,27 @@ window.Worlds = (() => {
});
});
- // Touch-Drag
+ // Pointer-Drag (funktioniert auf Mouse + Touch)
ov.querySelectorAll('.wc-chip').forEach(chip => {
- chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true });
+ chip.addEventListener('pointerdown', e => _onDragStart(e, chip));
});
- document.addEventListener('touchmove', _onDragMove, { passive: false });
- document.addEventListener('touchend', _onDragEnd);
}
function _onDragStart(e, chipEl) {
+ if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
if (_drag) _cancelDrag();
- const touch = e.touches[0];
- // Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte)
+ chipEl.setPointerCapture(e.pointerId);
_drag = {
page: chipEl.dataset.page, zone: chipEl.dataset.zone,
chipEl, ghost: null, dropZone: null, active: false,
- startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0,
+ startX: e.clientX, startY: e.clientY, ox: 0, oy: 0,
};
- document.addEventListener('touchmove', _onDragMove, { passive: false });
- document.addEventListener('touchend', _onDragEnd);
- document.addEventListener('touchcancel', _onDragEnd);
+ document.addEventListener('pointermove', _onDragMove);
+ document.addEventListener('pointerup', _onDragEnd);
+ document.addEventListener('pointercancel', _onDragEnd);
}
- function _activateDrag(touch) {
+ function _activateDrag(e) {
const rect = _drag.chipEl.getBoundingClientRect();
_drag.ox = _drag.startX - rect.left;
_drag.oy = _drag.startY - rect.top;
@@ -891,8 +908,8 @@ window.Worlds = (() => {
ghost.style.transform = 'scale(1.08) rotate(-2deg)';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
- ghost.style.left = (touch.clientX - _drag.ox) + 'px';
- ghost.style.top = (touch.clientY - _drag.oy) + 'px';
+ ghost.style.left = (e.clientX - _drag.ox) + 'px';
+ ghost.style.top = (e.clientY - _drag.oy) + 'px';
ghost.style.transition = 'none';
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
document.body.appendChild(ghost);
@@ -902,24 +919,22 @@ window.Worlds = (() => {
function _onDragMove(e) {
if (!_drag) return;
- const touch = e.touches[0];
if (!_drag.active) {
- const dx = Math.abs(touch.clientX - _drag.startX);
- const dy = Math.abs(touch.clientY - _drag.startY);
- if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht
- _activateDrag(touch);
+ const dx = Math.abs(e.clientX - _drag.startX);
+ const dy = Math.abs(e.clientY - _drag.startY);
+ if (dx < 8 && dy < 8) return;
+ _activateDrag(e);
}
- e.preventDefault(); // Scroll erst NACH Threshold blockieren
- _drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px';
- _drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px';
+ _drag.ghost.style.left = (e.clientX - _drag.ox) + 'px';
+ _drag.ghost.style.top = (e.clientY - _drag.oy) + 'px';
let foundZone = null;
ov.querySelectorAll('.wc-zone').forEach(z => {
const r = z.getBoundingClientRect();
- const over = touch.clientX >= r.left && touch.clientX <= r.right
- && touch.clientY >= r.top && touch.clientY <= r.bottom;
+ const over = e.clientX >= r.left && e.clientX <= r.right
+ && e.clientY >= r.top && e.clientY <= r.bottom;
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
if (over) foundZone = z.dataset.zone;
});
@@ -967,6 +982,19 @@ window.Worlds = (() => {
let _bgUrl = null; // aktuell gesetztes Hintergrundbild
+ function _isDarkMode() {
+ const t = document.documentElement.getAttribute('data-theme');
+ if (t === 'dark') return true;
+ if (t === 'light') return false;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+
+ function _bgWithOverlay(url) {
+ return _isDarkMode()
+ ? `linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.45)), url('${url}')`
+ : `url('${url}')`;
+ }
+
function _applyBgOrientation() {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
@@ -975,14 +1003,14 @@ window.Worlds = (() => {
if (portrait) {
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
ov.style.backgroundImage = '';
- track.style.backgroundImage = `url('${_bgUrl}')`;
+ track.style.backgroundImage = _bgWithOverlay(_bgUrl);
track.style.backgroundSize = 'cover';
track.style.backgroundPosition = 'center 40%';
track.style.backgroundRepeat = 'no-repeat';
} else {
// Vollbild pro Welt (Landscape / Desktop)
track.style.backgroundImage = '';
- ov.style.backgroundImage = `url('${_bgUrl}')`;
+ ov.style.backgroundImage = _bgWithOverlay(_bgUrl);
ov.style.backgroundSize = 'cover';
ov.style.backgroundPosition = 'center 40%';
ov.style.backgroundRepeat = 'no-repeat';
@@ -992,6 +1020,10 @@ window.Worlds = (() => {
// Orientierungswechsel → Bild neu setzen
window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation);
+ // Theme-Wechsel → Overlay-Intensität anpassen
+ new MutationObserver(_applyBgOrientation)
+ .observe(document.documentElement, { attributeFilter: ['data-theme'] });
+
function _applyBgImage(url) {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
@@ -1002,7 +1034,20 @@ window.Worlds = (() => {
_hasBgPhoto = true;
_bgUrl = url;
_applyBgOrientation();
- document.getElementById('wh-photo-hint')?.remove();
+ const hint = document.getElementById('wh-photo-hint');
+ if (hint) {
+ const seen = parseInt(localStorage.getItem('banyaro_wh_hint_seen') || '0');
+ if (seen < 2) {
+ localStorage.setItem('banyaro_wh_hint_seen', String(seen + 1));
+ setTimeout(() => {
+ hint.style.transition = 'opacity 0.6s';
+ hint.style.opacity = '0';
+ setTimeout(() => hint.remove(), 650);
+ }, 4000);
+ } else {
+ hint.remove();
+ }
+ }
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
@@ -1077,9 +1122,15 @@ window.Worlds = (() => {
} else if (!dog) { _applyBgImage(null); }
const hour = new Date().getHours();
- const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
+
+ // User-Geburtstag heute?
+ const _todayDdMm = (() => { const d = new Date(); return String(d.getDate()).padStart(2,'0')+'.'+String(d.getMonth()+1).padStart(2,'0'); })();
+ const userBdayToday = user?.geburtstag && user.geburtstag === _todayDdMm;
+ const greet = userBdayToday
+ ? `Herzlichen Glückwunsch`
+ : (hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend');
const stale = isOffline && staleMin > 5
? `
· Offline ` : '';
@@ -1104,6 +1155,26 @@ window.Worlds = (() => {
: (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️';
+ // User-Geburtstag Reminder
+ const userBdayHtml = userBdayToday ? `
+
+
+
+
+
+
+
+
+
+
+ Alles Gute zum Geburtstag, ${_esc(firstName)}!
+
+
+ Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
+
+
` : '';
+
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
@@ -1150,6 +1221,7 @@ window.Worlds = (() => {
${user ? userAvatarHtml : ''}
+ ${userBdayHtml}
${alertHtml}
${user && dog ? `
@@ -1186,7 +1258,6 @@ window.Worlds = (() => {
` : ''}
-
Deine Bereiche
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
@@ -1209,8 +1280,7 @@ window.Worlds = (() => {
try {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise;
- valEl.textContent = ex?.name || '—';
- // Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
+ valEl.textContent = ex?.name || 'Stand erfassen →';
const chip = document.getElementById('wj-exercise-chip');
if (chip) {
chip.style.cursor = 'pointer';
@@ -1221,7 +1291,7 @@ window.Worlds = (() => {
);
};
}
- } catch { valEl.textContent = '—'; }
+ } catch { valEl.textContent = 'Stand erfassen →'; }
}
async function _loadJetztRoute() {
@@ -1342,8 +1412,13 @@ window.Worlds = (() => {
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
return null;
}
- const bday = _birthdayState(dog.geburtstag);
- const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
+ const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null;
+ // Großes Banner nur wenn der AKTIVE Hund Geburtstag hat
+ const bday = (bdayDog && bdayDog.id === dog.id) ? _birthdayState(dog.geburtstag) : null;
+ const bdayYear = bday && dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
+
+ // Hinweis in Info-Karte wenn ein ANDERER Hund Geburtstag hat
+ const otherBdayDog = (bdayDog && bdayDog.id !== dog.id) ? bdayDog : null;
const [streakRes, diaryRes] = await Promise.allSettled([
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
@@ -1402,6 +1477,22 @@ window.Worlds = (() => {
${otherAvatarsHtml}
+ ${otherBdayDog ? `
+
+
+
+
+
+
+
+ ${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
+
+
+
+
+
` : ''}
${bday ? `