Compare commits

..

No commits in common. "9677d1e71a5e911526711c89f65f98230b456aa8" and "ebff9d820dbae011689a4eae60ffd3299267608b" have entirely different histories.

41 changed files with 478 additions and 4628 deletions

View file

@ -2398,65 +2398,6 @@ def _migrate(conn_factory):
except Exception as e: except Exception as e:
logger.warning(f"Migration route_dogs fehlgeschlagen: {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): def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -5,18 +5,12 @@ Unterstützt zwei Backends (wird automatisch gewählt):
2. SMTP wenn SMTP_HOST gesetzt (Fallback) 2. SMTP wenn SMTP_HOST gesetzt (Fallback)
""" """
import imaplib
import os import os
import base64
import smtplib import smtplib
import asyncio import asyncio
import logging import logging
import ssl
from datetime import datetime
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.utils import formatdate
import httpx import httpx
@ -30,77 +24,18 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "") SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "") SMTP_PASS = os.getenv("SMTP_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 <noreply@banyaro.app>") SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro <noreply@banyaro.app>")
APP_URL = os.getenv("APP_URL", "https://banyaro.app") 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 # Brevo REST-API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None): async def _send_brevo(to: str, subject: str, html: str, plain: str):
# Absender-Name und -Adresse aus SMTP_FROM parsen
# Format: "Ban Yaro <noreply@banyaro.app>" oder "noreply@banyaro.app"
from_raw = SMTP_FROM from_raw = SMTP_FROM
if "<" in from_raw: if "<" in from_raw:
from_name = from_raw[:from_raw.index("<")].strip() from_name = from_raw[:from_raw.index("<")].strip()
@ -117,14 +52,6 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
"textContent": plain, "textContent": plain,
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"}, "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: async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post( resp = await client.post(
BREVO_API_URL, BREVO_API_URL,
@ -137,50 +64,30 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# SMTP Fallback # SMTP Fallback
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None): def _send_smtp_sync(to: str, subject: str, html: str, plain: str):
if attachments: msg = MIMEMultipart("alternative")
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["Subject"] = subject
msg["From"] = SMTP_FROM msg["From"] = SMTP_FROM
msg["To"] = to msg["To"] = to
msg["Date"] = formatdate(localtime=False) msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
msg_bytes = msg.as_bytes()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
s.ehlo() s.ehlo()
s.starttls() s.starttls()
if SMTP_USER: if SMTP_USER:
s.login(SMTP_USER, SMTP_PASS) s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg_bytes) s.sendmail(SMTP_FROM, [to], msg.as_string())
_imap_save_sent(msg_bytes)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Öffentliche Funktion # Öffentliche Funktion
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): async def send_email(to: str, subject: str, html: str, plain: str = ""):
if BREVO_API_KEY: if BREVO_API_KEY:
try: try:
await _send_brevo(to, subject, html, plain, attachments) await _send_brevo(to, subject, html, plain)
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") 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 return
except Exception as e: except Exception as e:
logger.error(f"Brevo-Fehler: {e}") logger.error(f"Brevo-Fehler: {e}")
@ -189,9 +96,7 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme
if SMTP_HOST: if SMTP_HOST:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: try:
await loop.run_in_executor( await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain)
None, _send_smtp_sync, to, subject, html, plain, attachments
)
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
return return
except Exception as e: except Exception as e:

View file

@ -253,8 +253,6 @@ from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router from routes.help import router as help_router
from routes.feedback import router as feedback_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -319,8 +317,6 @@ app.include_router(challenges_router, prefix="/api/challenges", ta
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"])
app.include_router(invoices_router)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -410,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.") raise _HE(404, "Nicht gefunden.")
return _media_response(filepath) return _media_response(filepath)
APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():
@ -1724,8 +1720,8 @@ async def force_update():
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px} height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px}</style></head> p{color:#94a3b8;font-size:14px}</style></head>
<body> <body>
<div> Einen Moment</div> <div> Aktualisiere Ban Yaro</div>
<p id="s">Wir besorgen neue Leckerlis 🦴</p> <p id="s">Service Worker wird entfernt</p>
<script> <script>
// Zweiten Reload durch SW-updatefound verhindern // Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1'); sessionStorage.setItem('by_skip_sw_reload','1');

View file

@ -1,8 +1,5 @@
"""BAN YARO — Admin / Moderator Backend""" """BAN YARO — Admin / Moderator Backend"""
import asyncio import asyncio
import csv
import io
import logging
import os import os
import sys import sys
import time import time
@ -11,14 +8,11 @@ from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional
from database import db, DB_PATH from database import db, DB_PATH
from auth import get_current_user from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin") _TZ = ZoneInfo("Europe/Berlin")
@ -89,11 +83,6 @@ def require_admin(user=Depends(get_current_user)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"} _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
class QuarterlyReportBody(BaseModel):
year: int
quarter: int
email: str
class UserPatch(BaseModel): class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None is_moderator: Optional[int] = None
@ -141,12 +130,6 @@ async def action_items(user=Depends(require_mod)):
).fetchone()[0] ).fetchone()[0]
except Exception: except Exception:
upgrades_pending = 0 upgrades_pending = 0
try:
invoices_unpaid = conn.execute(
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
).fetchone()[0]
except Exception:
invoices_unpaid = 0
return { return {
"jobs_pending": jobs, "jobs_pending": jobs,
"breeder_pending": breeders, "breeder_pending": breeders,
@ -155,7 +138,6 @@ async def action_items(user=Depends(require_mod)):
"poi_edits_pending": poi_edits, "poi_edits_pending": poi_edits,
"users_today": users_today, "users_today": users_today,
"upgrades_pending": upgrades_pending, "upgrades_pending": upgrades_pending,
"invoices_unpaid": invoices_unpaid,
} }
@ -1137,35 +1119,20 @@ async def list_upgrade_requests(user=Depends(require_admin)):
with db() as conn: with db() as conn:
rows = conn.execute(""" rows = conn.execute("""
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
u.name, u.email, u.billing_address, u.name, u.email
u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM upgrade_requests r FROM upgrade_requests r
JOIN users u ON u.id = r.user_id JOIN users u ON u.id = r.user_id
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
LIMIT 100 LIMIT 100
""").fetchall() """).fetchall()
result = [] return [dict(r) for r in rows]
for r in rows:
d = dict(r)
d_info = _get_discount_info(conn, r["user_id"])
d["discount_pct"] = d_info["discount_pct"]
d["discount_reason"] = d_info["reason"]
result.append(d)
return result
@router.get("/users/{user_id}/discount")
def get_user_discount(user_id: int, admin=Depends(require_admin)):
with db() as conn:
return _get_discount_info(conn, user_id)
@router.post("/upgrade-requests/{req_id}/fulfill") @router.post("/upgrade-requests/{req_id}/fulfill")
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
with db() as conn: with db() as conn:
req = conn.execute( req = conn.execute(
"SELECT r.*, u.name, u.email, u.subscription_tier AS old_tier FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?", "SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
(req_id,) (req_id,)
).fetchone() ).fetchone()
if not req: if not req:
@ -1274,296 +1241,4 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
import logging import logging
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}") logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
# Offene Rechnungen (sent/draft) des alten Tiers stornieren + neuen Entwurf anlegen return {"ok": True, "tier": req["tier"], "user": req["name"]}
inv_number = None
try:
inv_number = await _handle_upgrade_invoices(req, tier_label)
except Exception as e:
logger.warning(f"Upgrade-Rechnungslogik fehlgeschlagen für {req['name']}: {e}")
return {"ok": True, "tier": req["tier"], "user": req["name"], "invoice_number": inv_number}
def _get_discount_info(conn, user_id: int) -> dict:
"""Berechnet Rabatt für einen User basierend auf Gründer-Status und Referrals."""
row = conn.execute(
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM users u WHERE u.id=?""",
(user_id,)
).fetchone()
if not row:
return {"discount_pct": 0, "reason": None, "referral_count": 0}
if row["is_founder"] or row["is_founder_pending"]:
return {"discount_pct": 100, "reason": "founder", "referral_count": row["referral_count"]}
referred_by = row["referred_by"] or 0
if referred_by > 0:
referrer = conn.execute(
"SELECT is_founder, is_founder_pending FROM users WHERE id=?", (referred_by,)
).fetchone()
if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]):
return {"discount_pct": 100, "reason": "referred_by_founder", "referral_count": row["referral_count"]}
count = row["referral_count"]
for threshold, pct in [(50, 50), (20, 30), (10, 20)]:
if count >= threshold:
return {"discount_pct": pct, "reason": "referral", "referral_count": count}
return {"discount_pct": 0, "reason": None, "referral_count": count}
async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
"""Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an."""
from routes.invoices import _next_invoice_number
from datetime import timedelta
with db() as conn:
# Offene Rechnungen (draft + sent) dieses Users finden
open_invoices = conn.execute(
"SELECT * FROM invoices WHERE user_id=? AND status IN ('draft','sent')",
(req["user_id"],)
).fetchall()
for inv in open_invoices:
cancel_num = _next_invoice_number(conn, "ST")
conn.execute(
"""UPDATE invoices SET status='cancelled', cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now'),
cancellation_reason=?, cancellation_number=? WHERE id=?""",
(f"Tarif-Upgrade auf {new_tier_label}", cancel_num, inv["id"])
)
logger.info(f"Rechnung {inv['invoice_number']} storniert ({cancel_num}) — Upgrade auf {new_tier_label}")
# Neuen Entwurf für den neuen Tier anlegen
tier = req["tier"]
price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00)
today = datetime.now(_TZ).date()
end_date = today.replace(year=today.year + 1) - timedelta(days=1)
period = f"{today.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
description = f"{new_tier_label} Jahresabo"
billing = conn.execute(
"SELECT billing_address FROM users WHERE id=?", (req["user_id"],)
).fetchone()
billing_address = billing["billing_address"] if billing else None
disc_info = _get_discount_info(conn, req["user_id"])
discount_pct = disc_info["discount_pct"]
discount_amt = round(price * discount_pct / 100, 2)
after_disc = round(price - discount_amt, 2)
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
if disc_info["reason"] == "founder":
note = f"Gründer-Sonderkonditionen: {new_tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB}"
elif disc_info["reason"] == "referred_by_founder":
note = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB}"
elif disc_info["reason"] == "referral":
note = f"Herzlichen Dank für deine Unterstützung! Für {disc_info['referral_count']} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB}"
else:
note = f"{_AGB} (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})"
inv_number = _next_invoice_number(conn)
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
""", (
inv_number, req["user_id"], req["name"], req["email"], billing_address,
description, period, price, discount_pct, discount_amt, after_disc, after_disc, note,
))
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
(invoice_id, description, price, price)
)
logger.info(f"Neuer Rechnungsentwurf {inv_number} für {req['email']} nach Upgrade auf {new_tier_label}")
return inv_number
# ------------------------------------------------------------------
# Helpers: Quartalsdaten
# ------------------------------------------------------------------
def _quarter_bounds(year: int, q: int):
"""Gibt (start_date, end_date) als ISO-Strings zurück (YYYY-MM-DD)."""
if q not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
month_start = (q - 1) * 3 + 1
month_end = month_start + 2
# Letzter Tag des Endmonats
import calendar
last_day = calendar.monthrange(year, month_end)[1]
return (
f"{year:04d}-{month_start:02d}-01",
f"{year:04d}-{month_end:02d}-{last_day:02d}",
)
def _fetch_quarter_invoices(conn, year: int, q: int):
"""Liest alle bezahlten/gesendeten Rechnungen des Quartals."""
start, end = _quarter_bounds(year, q)
rows = conn.execute("""
SELECT invoice_number, created_at, recipient_name, recipient_email,
amount_net, tax_amount, amount_gross,
status, paid_at, paid_amount
FROM invoices
WHERE status IN ('paid', 'sent')
AND DATE(created_at) BETWEEN ? AND ?
ORDER BY created_at ASC
""", (start, end)).fetchall()
return rows, start, end
def _build_csv(rows) -> bytes:
"""Erstellt CSV-Bytes aus den Rechnungszeilen."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"Rechnungsnummer", "Datum", "Empfänger", "E-Mail",
"Nettobetrag", "Steuer", "Bruttobetrag",
"Status", "Bezahlt-am", "Gezahlter-Betrag",
])
for r in rows:
# Datum auf YYYY-MM-DD kürzen
datum = (r["created_at"] or "")[:10]
paid_at = (r["paid_at"] or "")[:10] if r["paid_at"] else ""
writer.writerow([
r["invoice_number"],
datum,
r["recipient_name"],
r["recipient_email"],
f"{r['amount_net']:.2f}".replace(".", ","),
f"{r['tax_amount']:.2f}".replace(".", ","),
f"{r['amount_gross']:.2f}".replace(".", ","),
r["status"],
paid_at,
f"{r['paid_amount']:.2f}".replace(".", ",") if r["paid_amount"] is not None else "",
])
return buf.getvalue().encode("utf-8-sig") # BOM für Excel-Kompatibilität
# ------------------------------------------------------------------
# GET /api/admin/invoices/quarterly/{year}/{q}/csv
# ------------------------------------------------------------------
@router.get("/invoices/quarterly/{year}/{q}/csv")
async def invoice_quarterly_csv(year: int, q: int, user=Depends(require_admin)):
"""CSV-Download aller Rechnungen eines Quartals (paid + sent)."""
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, year, q)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{year}_Q{q}.csv"
logger.info(f"CSV-Download Q{q}/{year}: {len(rows)} Rechnungen → {filename}")
return Response(
content=csv_bytes,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# POST /api/admin/invoices/send-quarterly-report
# ------------------------------------------------------------------
@router.post("/invoices/send-quarterly-report")
async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_admin)):
"""Sendet Quartalsbericht als CSV-Anhang an Steuerberater + Zusammenfassung an René."""
if data.quarter not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, data.year, data.quarter)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{data.year}_Q{data.quarter}.csv"
# Zusammenfassungs-Zahlen
total_net = sum(r["amount_net"] for r in rows)
total_tax = sum(r["tax_amount"] for r in rows)
total_gross = sum(r["amount_gross"] for r in rows)
count_paid = sum(1 for r in rows if r["status"] == "paid")
count_sent = sum(1 for r in rows if r["status"] == "sent")
subject_stb = (
f"Ban Yaro - Rechnungen Q{data.quarter}/{data.year} "
f"({start} bis {end})"
)
body_stb = (
f"Hallo,\n\n"
f"anbei die Rechnungsübersicht für Q{data.quarter}/{data.year} "
f"({start} bis {end}).\n\n"
f"Anzahl Rechnungen: {len(rows)}\n"
f" davon bezahlt: {count_paid}\n"
f" davon ausstehend: {count_sent}\n\n"
f"Summe Netto: {total_net:>10.2f} EUR\n"
f"Summe Steuer: {total_tax:>10.2f} EUR\n"
f"Summe Brutto: {total_gross:>10.2f} EUR\n\n"
f"Die vollständige Liste finden Sie als CSV-Anhang.\n\n"
f"Viele Grüße\nRené Nitzsche / Ban Yaro"
)
from mailer import send_email, SMTP_FROM
# Steuerberater-Mail (mit CSV-Anhang wenn unterstützt)
try:
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
attachments=[{
"filename": filename,
"content": csv_bytes,
"content_type": "text/csv",
}],
)
logger.info(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (mit Anhang)")
except TypeError:
# send_email unterstützt noch kein attachments-Argument → ohne Anhang senden
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
)
logger.warning(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (OHNE Anhang, attachments nicht unterstützt)")
# Zusammenfassung an René (SMTP_FROM-Adresse)
# Reine E-Mail-Adresse aus "Name <addr>" extrahieren
from_addr = SMTP_FROM
if "<" in from_addr:
from_addr = from_addr[from_addr.index("<") + 1 : from_addr.index(">")].strip()
subject_rene = f"[Ban Yaro Admin] Quartalsbericht Q{data.quarter}/{data.year} versendet"
body_rene = (
f"Der Quartalsbericht Q{data.quarter}/{data.year} wurde an {data.email} gesendet.\n\n"
f"Zeitraum: {start} bis {end}\n"
f"Rechnungen gesamt: {len(rows)} (bezahlt: {count_paid}, ausstehend: {count_sent})\n\n"
f"Netto: {total_net:>10.2f} EUR\n"
f"Steuer: {total_tax:>10.2f} EUR\n"
f"Brutto: {total_gross:>10.2f} EUR\n"
)
try:
await send_email(
from_addr,
subject_rene,
f"<pre style='font-family:monospace'>{body_rene}</pre>",
body_rene,
)
except Exception as e:
logger.warning(f"Zusammenfassungs-Mail an René fehlgeschlagen: {e}")
return {
"ok": True,
"sent_to": data.email,
"year": data.year,
"quarter": data.quarter,
"period": f"{start} - {end}",
"count": len(rows),
"count_paid": count_paid,
"count_sent": count_sent,
"total_net": round(total_net, 2),
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
}

View file

@ -241,8 +241,7 @@ async def me(user=Depends(get_current_user)):
is_founder, is_partner, founder_number, is_founder_pending, is_founder, is_partner, founder_number, is_founder_pending,
notes_ki_enabled, gassi_stunde_push, notes_ki_enabled, gassi_stunde_push,
preferred_theme, subscription_tier, preferred_theme, subscription_tier,
subscription_expires_at, subscription_cancelled_at, needs_dog_selection, subscription_expires_at, subscription_cancelled_at, needs_dog_selection
billing_address, geburtstag
FROM users WHERE id=?""", FROM users WHERE id=?""",
(user["id"],) (user["id"],)
).fetchone() ).fetchone()

View file

@ -1,52 +0,0 @@
"""
BAN YARO Öffentliches Kontaktformular (kein Login erforderlich)
Für Impressum-Kontaktpflicht nach § 5 DDG.
"""
from fastapi import APIRouter, Request
from pydantic import BaseModel, EmailStr, Field
from typing import Annotated
from mailer import send_email, email_html
from ratelimit import check as rl_check
router = APIRouter()
CONTACT_MAIL = "hallo@banyaro.app"
class ContactIn(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=100)]
email: EmailStr
subject: Annotated[str, Field(min_length=3, max_length=150)]
message: Annotated[str, Field(min_length=10, max_length=3000)]
@router.post("")
async def submit_contact(payload: ContactIn, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key=f"contact_{payload.email}")
body = f"""
<p style="margin:0 0 16px">Neue Kontaktanfrage über das Impressum-Formular:</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600;width:100px">Name</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.name}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">E-Mail</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.email}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">Betreff</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.subject}</td></tr>
</table>
<div style="background:#fdf6ef;border-left:4px solid #C4843A;padding:14px 16px;
border-radius:0 8px 8px 0;white-space:pre-wrap;font-size:14px;line-height:1.6">
{payload.message}
</div>"""
plain = f"Kontakt von {payload.name} ({payload.email})\nBetreff: {payload.subject}\n\n{payload.message}"
await send_email(
CONTACT_MAIL,
f"Kontakt: {payload.subject}{payload.name}",
email_html(body),
plain,
)
return {"ok": True}

View file

@ -1,819 +0,0 @@
"""BAN YARO — Rechnungs-System (Admin)"""
import os
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from database import db
from auth import require_admin
import mailer
router = APIRouter(prefix="/api/admin/invoices", tags=["invoices"])
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class InvoiceItem(BaseModel):
description: str
quantity: float = 1.0
unit_price: float
class InvoiceCreate(BaseModel):
user_id: Optional[int] = None
recipient_name: str
recipient_email: str
recipient_address: Optional[str] = None
items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0
service_period: Optional[str] = None
notes: Optional[str] = None
class PayBody(BaseModel):
paid_at: str
paid_amount: float
notes: Optional[str] = None
class CancelBody(BaseModel):
reason: str
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _next_invoice_number(conn, prefix="RG"):
year = datetime.now().year
last = conn.execute(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
if last:
n = int(last[0].split("-")[-1]) + 1
else:
n = 1
return f"{prefix}-{year}-{n:04d}"
def _generate_pdf(invoice, items) -> bytes:
from fpdf import FPDF
from datetime import datetime, timedelta
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
STEUERNUMMER = os.getenv("STEUERNUMMER", "")
INHABER = os.getenv("RECHNUNG_INHABER", "Rene Degelmann")
FIRMA = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro")
STRASSE = os.getenv("RECHNUNG_STRASSE", "")
PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "")
EMAIL = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app")
WEBSITE = os.getenv("RECHNUNG_WEBSITE", "banyaro.app")
IBAN = os.getenv("RECHNUNG_IBAN", "")
BIC = os.getenv("RECHNUNG_BIC", "")
BANKNAME = os.getenv("RECHNUNG_BANK", "")
OR = (230, 126, 34)
DK = (30, 30, 30)
GY = (130, 130, 130)
LG = (245, 245, 245)
WH = (255, 255, 255)
def _s(text) -> str:
"""Nicht-Latin1-Zeichen ersetzen bevor sie an fpdf Helvetica übergeben werden."""
if not text:
return ""
return (str(text)
.replace("", "-").replace("", "-") # En/Em-Dash
.replace("", "'").replace("", "'") # Typogr. Anf.zeichen
.replace("", '"').replace("", '"')
.replace("", "...").replace("·", ".")
.replace("", "EUR") # € falls doch
)
def eur(v: float) -> str:
s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{s} EUR"
def fdate(iso: str) -> str:
try:
y, m, d = (iso or "")[:10].split("-")
return f"{d}.{m}.{y}"
except Exception:
return (iso or "")[:10]
try:
due_date = (datetime.fromisoformat(invoice["created_at"][:10]) + timedelta(days=14)).strftime("%d.%m.%Y")
except Exception:
due_date = ""
icon_path = os.path.join(os.path.dirname(__file__), "..", "static", "icons", "icon-192.png")
icon_path = os.path.abspath(icon_path)
pdf = FPDF()
pdf.add_page()
pdf.set_margins(20, 0, 20)
pdf.set_auto_page_break(auto=True, margin=22)
W = 170
# ── Header-Balken (volle Breite, 16mm) ───────────────────────
pdf.set_fill_color(*OR)
pdf.rect(0, 0, 210, 16, "F")
# App-Icon links im Balken
if os.path.exists(icon_path):
pdf.image(icon_path, x=18, y=1, w=14, h=14)
# "Ban Yaro" in Weiss rechts im Balken
pdf.set_xy(20, 1)
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*WH)
pdf.cell(W, 14, FIRMA, align="R")
# ── Absenderadresse rechts (unterhalb Balken) ─────────────────
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*GY)
y_addr = 19
for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]):
pdf.set_xy(20, y_addr)
pdf.cell(W, 4, line, align="R")
y_addr += 4.2
# ── Absenderzeile + Trennstrich (DIN 5008) ────────────────────
sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT]))
pdf.set_xy(20, 46)
pdf.set_font("Helvetica", "", 6.5)
pdf.set_text_color(*GY)
pdf.cell(85, 3.5, sender_ref)
pdf.set_draw_color(*GY)
pdf.set_line_width(0.15)
pdf.line(20, 50, 105, 50)
# ── Empfänger links ───────────────────────────────────────────
pdf.set_xy(20, 52)
pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(*DK)
pdf.cell(85, 5.5, _s(invoice["recipient_name"]), new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
if invoice.get("recipient_address"):
for line in str(invoice["recipient_address"]).split("\n"):
if line.strip():
pdf.set_x(20)
pdf.cell(85, 5, _s(line.strip()), new_x="LMARGIN", new_y="NEXT")
pdf.set_x(20)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(85, 5, _s(invoice["recipient_email"]))
# ── Info-Block rechts ─────────────────────────────────────────
info_rows = [
("Rechnungsnummer", invoice["invoice_number"]),
("Datum", fdate(invoice.get("created_at", ""))),
("Fällig bis", due_date),
]
if invoice.get("service_period"):
info_rows.append(("Leistungszeitraum", _s(invoice["service_period"])))
y_info = 52
for lbl, val in info_rows:
pdf.set_xy(110, y_info)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(35, 5.5, lbl + ":")
pdf.set_font("Helvetica", "B", 8.5)
pdf.set_text_color(*DK)
pdf.cell(25, 5.5, val)
y_info += 6
# ── Betreff ───────────────────────────────────────────────────
pdf.set_xy(20, 90)
pdf.set_font("Helvetica", "B", 13)
pdf.set_text_color(*DK)
pdf.cell(W, 7, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(*OR)
pdf.set_line_width(0.6)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(4)
# ── Positionen-Tabelle ────────────────────────────────────────
CW = (90, 18, 32, 30)
pdf.set_fill_color(*OR)
pdf.set_text_color(*WH)
pdf.set_font("Helvetica", "B", 9)
pdf.cell(CW[0], 7, " Beschreibung", fill=True)
pdf.cell(CW[1], 7, "Menge", fill=True, align="C")
pdf.cell(CW[2], 7, "Einzelpreis", fill=True, align="R")
pdf.cell(CW[3], 7, "Gesamt", fill=True, align="R", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(*DK)
pdf.set_font("Helvetica", "", 9)
pdf.set_line_width(0.2)
pdf.set_draw_color(200, 200, 200)
for i, item in enumerate(items):
pdf.set_fill_color(*(LG if i % 2 == 0 else WH))
qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".")
pdf.cell(CW[0], 7, f" {_s(str(item['description']))[:64]}", border="B", fill=True)
pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C")
pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R")
pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R",
new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
# ── Summenblock ───────────────────────────────────────────────
def srow(lbl, val, bold=False, txt_color=None, bg=None):
pdf.set_x(110)
pdf.set_fill_color(*(bg or WH))
pdf.set_text_color(*(txt_color or DK))
pdf.set_font("Helvetica", "B" if bold else "", 10 if bold else 9)
pdf.cell(50, 6, lbl, align="R", fill=bool(bg))
pdf.cell(30, 6, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT")
srow("Nettobetrag:", eur(invoice["amount_net"]))
if invoice.get("discount_pct") and invoice["discount_pct"] > 0:
srow(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"- {eur(invoice['discount_amount'])}", txt_color=OR)
srow("Nach Rabatt:", eur(invoice["amount_after_discount"]))
if not KLEINUNTERNEHMER and invoice.get("tax_rate", 0) > 0:
srow(f"MwSt. {invoice['tax_rate']:.0f}%:", eur(invoice["tax_amount"]))
pdf.set_draw_color(*OR)
pdf.set_line_width(0.5)
pdf.line(110, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1)
srow("Gesamtbetrag:", eur(invoice["amount_gross"]), bold=True, bg=LG)
pdf.ln(3)
# ── §19-Hinweis ───────────────────────────────────────────────
if KLEINUNTERNEHMER:
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 8.5)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet."))
# ── Zahlungsinfo-Box ──────────────────────────────────────────
pdf.ln(5)
y_box = pdf.get_y()
pdf.set_x(24)
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(*OR)
pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*DK)
pay_rows = []
if due_date: pay_rows.append(("Zahlbar bis:", due_date))
if IBAN: pay_rows.append(("IBAN:", IBAN))
if BIC: pay_rows.append(("BIC:", BIC))
if BANKNAME: pay_rows.append(("Bank:", BANKNAME))
pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"]))
for lbl, val in pay_rows:
pdf.set_x(24)
pdf.set_font("Helvetica", "", 9)
pdf.cell(45, 5.5, lbl)
pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9)
pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT")
pdf.set_fill_color(*OR)
pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F")
# ── Notizen ───────────────────────────────────────────────────
if invoice.get("notes"):
pdf.ln(4)
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s(str(invoice["notes"])))
# ── Footer (fixiert auf Seite 1, kein auto-break) ─────────────
pdf.set_auto_page_break(False)
pdf.set_y(277)
pdf.set_draw_color(*OR)
pdf.set_line_width(0.4)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1.5)
footer_parts = [FIRMA, INHABER]
if STEUERNUMMER:
footer_parts.append(f"Steuernr.: {STEUERNUMMER}")
if EMAIL:
footer_parts.append(EMAIL)
if WEBSITE:
footer_parts.append(WEBSITE)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*GY)
pdf.set_x(20)
pdf.cell(W, 4, " | ".join(footer_parts), align="C")
return bytes(pdf.output())
async def _save_to_paperless(pdf_bytes: bytes, invoice_number: str, filename: str):
scaninput = os.getenv("SCANINPUT_DIR", "/scaninput")
os.makedirs(scaninput, exist_ok=True)
path = os.path.join(scaninput, filename)
with open(path, "wb") as f:
f.write(pdf_bytes)
logger.info(f"PDF gespeichert: {path} ({len(pdf_bytes)} Bytes)")
paperless_url = os.getenv("PAPERLESS_URL", "")
paperless_token = os.getenv("PAPERLESS_TOKEN", "")
if paperless_url and paperless_token:
try:
import httpx
async with httpx.AsyncClient(timeout=30) as client:
await client.post(
f"{paperless_url}/api/documents/post_document/",
headers={"Authorization": f"Token {paperless_token}"},
files={"document": (filename, pdf_bytes, "application/pdf")},
data={"title": invoice_number, "tags": "banyaro,Rechnung"},
)
except Exception as e:
logger.warning(f"Paperless upload failed: {e}")
def _row_to_dict(row) -> dict:
return dict(row)
def _fetch_items(conn, invoice_id: int) -> list:
rows = conn.execute(
"SELECT * FROM invoice_items WHERE invoice_id=? ORDER BY id",
(invoice_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Endpoints
# ------------------------------------------------------------------
@router.get("")
def list_invoices(status: Optional[str] = None, admin=Depends(require_admin)):
with db() as conn:
if status:
rows = conn.execute(
"SELECT * FROM invoices WHERE status=? ORDER BY id DESC",
(status,)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM invoices ORDER BY id DESC"
).fetchall()
return [_row_to_dict(r) for r in rows]
@router.get("/cashflow")
def get_cashflow(admin=Depends(require_admin)):
with db() as conn:
monthly = conn.execute("""
SELECT substr(created_at, 1, 7) AS month,
SUM(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END) AS revenue,
COUNT(*) AS count
FROM invoices
WHERE status IN ('sent', 'paid')
GROUP BY month
ORDER BY month DESC
""").fetchall()
year = datetime.now().year
total_year = conn.execute(
"""SELECT COALESCE(SUM(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END), 0)
FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?""",
(f"{year}%",)
).fetchone()[0]
total_outstanding = conn.execute(
"SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status='sent'"
).fetchone()[0]
total_paid = conn.execute(
"SELECT COALESCE(SUM(COALESCE(paid_amount, amount_gross)),0) FROM invoices WHERE status='paid'"
).fetchone()[0]
counts_rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM invoices GROUP BY status"
).fetchall()
counts = {r["status"]: r["n"] for r in counts_rows}
return {
"monthly": [_row_to_dict(r) for r in monthly],
"total_year": round(total_year, 2),
"total_outstanding": round(total_outstanding, 2),
"total_paid": round(total_paid, 2),
"counts": counts,
}
@router.get("/quarterly/{year}/{q}")
def get_quarterly(year: int, q: int, admin=Depends(require_admin)):
if q not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
month_start = (q - 1) * 3 + 1
month_end = month_start + 2
from_date = f"{year}-{month_start:02d}-01"
import calendar
last_day = calendar.monthrange(year, month_end)[1]
to_date = f"{year}-{month_end:02d}-{last_day:02d}"
labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."}
ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."}
period = f"Q{q} {year} ({labels[q]} - {ends[q]})"
with db() as conn:
# Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum)
rows = conn.execute(
"SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Stornorechnungen die im Quartal ausgestellt wurden (cancelled_at im Zeitraum,
# auch wenn die Originalrechnung außerhalb des Quartals liegt)
storno_rows = conn.execute(
"SELECT * FROM invoices WHERE status = 'cancelled' AND cancelled_at >= ? AND cancelled_at <= ?",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Buchungseinträge aufbauen
entries = []
# Originalrechnungen (paid, sent — mit positivem Betrag)
for r in rows:
d = _row_to_dict(r)
if d["status"] in ("paid", "sent"):
entries.append(d)
elif d["status"] == "cancelled":
# Originalrechnung erscheint mit positivem Betrag (wurde ausgestellt)
entries.append(d)
# Stornozeilen: negative Beträge, Datum = cancelled_at, Nummer = cancellation_number
storno_ids_already = {r["id"] for r in rows}
for r in storno_rows:
d = _row_to_dict(r)
storno_entry = {
"invoice_number": d["cancellation_number"] or f"ST-{d['invoice_number']}",
"recipient_name": d["recipient_name"],
"recipient_email": d["recipient_email"],
"created_at": d["cancelled_at"],
"service_period": d["service_period"],
"amount_net": -round(d["amount_net"], 2),
"tax_amount": -round(d.get("tax_amount") or 0, 2),
"amount_gross": -round(d["amount_gross"], 2),
"paid_amount": None,
"status": "storno",
"sent_at": None,
"paid_at": None,
"cancellation_number": d["cancellation_number"],
"notes": f"Storno zu {d['invoice_number']}",
}
entries.append(storno_entry)
# Wenn Original NICHT im Quartal aber Storno schon → Original trotzdem zeigen
if r["id"] not in storno_ids_already:
orig = _row_to_dict(r)
entries.append(orig)
# Nach Datum sortieren
entries.sort(key=lambda e: (e.get("created_at") or ""))
# Summen: alle Einträge — Storno (-) und Original (+) heben sich gegenseitig auf
# Für bezahlte Rechnungen den tatsächlich eingegangenen Betrag verwenden
def _effective_gross(e):
if e.get("status") == "paid" and e.get("paid_amount") is not None:
return e["paid_amount"]
return e.get("amount_gross") or 0
total_gross = sum(_effective_gross(e) for e in entries)
total_tax = sum(e.get("tax_amount") or 0 for e in entries)
return {
"period": period,
"invoices": entries,
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
"count": len(entries),
}
@router.get("/{invoice_id}")
def get_invoice(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.patch("/{invoice_id}")
def update_invoice(invoice_id: int, data: InvoiceCreate, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] != "draft":
raise HTTPException(400, "Nur Entwürfe können bearbeitet werden.")
if not data.items:
raise HTTPException(400, "Mindestens eine Position erforderlich.")
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19"))
amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2)
discount_pct = data.discount_pct or 0.0
discount_amount = round(amount_net * discount_pct / 100, 2)
amount_after_discount = round(amount_net - discount_amount, 2)
tax_amount = round(amount_after_discount * TAX_RATE / 100, 2)
amount_gross = round(amount_after_discount + tax_amount, 2)
description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen"
conn.execute("""
UPDATE invoices SET
recipient_name=?, recipient_email=?, recipient_address=?,
description=?, service_period=?,
amount_net=?, discount_pct=?, discount_amount=?,
amount_after_discount=?, tax_rate=?, tax_amount=?, amount_gross=?,
notes=?
WHERE id=?
""", (
data.recipient_name, data.recipient_email, data.recipient_address,
description, data.service_period,
amount_net, discount_pct, discount_amount,
amount_after_discount, TAX_RATE, tax_amount, amount_gross,
data.notes, invoice_id,
))
conn.execute("DELETE FROM invoice_items WHERE invoice_id=?", (invoice_id,))
for item in data.items:
total = round(item.quantity * item.unit_price, 2)
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)",
(invoice_id, item.description, item.quantity, item.unit_price, total)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.post("", status_code=201)
def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)):
if not data.items:
raise HTTPException(400, "Mindestens eine Position erforderlich.")
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19"))
amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2)
discount_pct = data.discount_pct or 0.0
discount_amount = round(amount_net * discount_pct / 100, 2)
amount_after_discount = round(amount_net - discount_amount, 2)
tax_amount = round(amount_after_discount * TAX_RATE / 100, 2)
amount_gross = round(amount_after_discount + tax_amount, 2)
description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen"
with db() as conn:
invoice_number = _next_invoice_number(conn)
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
invoice_number, data.user_id, data.recipient_name, data.recipient_email,
data.recipient_address, description, data.service_period,
amount_net, discount_pct, discount_amount,
amount_after_discount, TAX_RATE, tax_amount, amount_gross,
data.notes,
))
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
for item in data.items:
total = round(item.quantity * item.unit_price, 2)
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)",
(invoice_id, item.description, item.quantity, item.unit_price, total)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.post("/{invoice_id}/send")
async def send_invoice(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.")
if row["status"] == "paid":
raise HTTPException(400, "Bezahlte Rechnung kann nicht erneut gesendet werden.")
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
try:
pdf_bytes = _generate_pdf(invoice, items)
except Exception as e:
logger.error(f"PDF-Generierung fehlgeschlagen: {e}")
raise HTTPException(500, f"PDF-Generierung fehlgeschlagen: {e}")
filename = f"{invoice['invoice_number']}_banyaro.pdf"
try:
await _save_to_paperless(pdf_bytes, invoice["invoice_number"], filename)
except Exception as e:
logger.warning(f"Paperless-Speicherung fehlgeschlagen: {e}")
import base64
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
anbei erhalten Sie Ihre Rechnung <b>{invoice['invoice_number']}</b>
über <b>{invoice['amount_gross']:.2f} EUR</b>.
</p>
<p style="margin:0 0 8px">Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.</p>
<p style="margin:0;font-size:13px;color:#888">Verwendungszweck: {invoice['invoice_number']}</p>
"""
html = mailer.email_html(body_html)
plain = (
f"Hallo {invoice['recipient_name']},\n\n"
f"anbei Ihre Rechnung {invoice['invoice_number']} über {invoice['amount_gross']:.2f} EUR.\n\n"
f"Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.\n"
f"Verwendungszweck: {invoice['invoice_number']}\n"
)
attachments = [{
"filename": filename,
"content": pdf_bytes,
"content_type": "application/pdf",
}]
try:
await mailer.send_email(
to=invoice["recipient_email"],
subject=f"Ihre Rechnung {invoice['invoice_number']} von Ban Yaro",
html=html,
plain=plain,
attachments=attachments,
)
except Exception as e:
logger.error(f"Mail-Versand fehlgeschlagen: {e}")
raise HTTPException(500, f"Mail-Versand fehlgeschlagen: {e}")
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
with db() as conn:
conn.execute(
"UPDATE invoices SET status='sent', sent_at=? WHERE id=?",
(now, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
return _row_to_dict(row)
@router.get("/{invoice_id}/pdf")
def download_pdf(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
pdf_bytes = _generate_pdf(invoice, items)
filename = f"{invoice['invoice_number']}_banyaro.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{invoice_id}/pay")
def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.")
if data.notes:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=?, notes=? WHERE id=?",
(data.paid_at, data.paid_amount, data.notes, invoice_id)
)
else:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?",
(data.paid_at, data.paid_amount, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
return _row_to_dict(row)
@router.post("/{invoice_id}/cancel")
async def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Rechnung ist bereits storniert.")
cancellation_number = _next_invoice_number(conn, "ST")
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
conn.execute(
"UPDATE invoices SET status='cancelled', cancelled_at=?, cancellation_reason=?, cancellation_number=? WHERE id=?",
(now, data.reason, cancellation_number, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
# Storno-PDF: invoice-Dict als Stornobeleg aufbereiten
orig_date = (invoice.get("created_at") or "")[:10]
try:
from datetime import datetime as _dt
y, m, d = orig_date.split("-")
orig_date_de = f"{d}.{m}.{y}"
except Exception:
orig_date_de = orig_date
storno_invoice = dict(invoice)
storno_invoice["invoice_number"] = cancellation_number
storno_invoice["notes"] = (
f"Stornorechnung zu Rechnung {invoice['invoice_number']} vom {orig_date_de}\n"
f"Grund: {data.reason}"
)
storno_invoice["amount_net"] = -invoice["amount_net"]
storno_invoice["discount_amount"] = -invoice.get("discount_amount", 0)
storno_invoice["amount_after_discount"] = -invoice["amount_after_discount"]
storno_invoice["tax_amount"] = -invoice.get("tax_amount", 0)
storno_invoice["amount_gross"] = -invoice["amount_gross"]
for item in items:
item["unit_price"] = -item["unit_price"]
item["total"] = -item["total"]
try:
pdf_bytes = _generate_pdf(storno_invoice, items)
except Exception as e:
logger.error(f"Storno-PDF fehlgeschlagen: {e}")
return _row_to_dict(row)
filename = f"{cancellation_number}_banyaro.pdf"
try:
await _save_to_paperless(pdf_bytes, cancellation_number, filename)
except Exception as e:
logger.warning(f"Storno Paperless fehlgeschlagen: {e}")
# Mail an Kunden
try:
body_html = mailer.email_html(f"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
Ihre Rechnung <b>{invoice['invoice_number']}</b> wurde storniert
(Stornonummer: <b>{cancellation_number}</b>).
</p>
<p style="margin:0 0 8px;color:#666;font-size:13px">Grund: {data.reason}</p>
<p style="margin:0;color:#666;font-size:13px">
Das Stornodokument liegt diesem Schreiben bei.
</p>
""")
plain = (
f"Hallo {invoice['recipient_name']},\n\n"
f"Ihre Rechnung {invoice['invoice_number']} wurde storniert "
f"(Stornonummer: {cancellation_number}).\n"
f"Grund: {data.reason}\n"
)
await mailer.send_email(
to=invoice["recipient_email"],
subject=f"Stornierung Rechnung {invoice['invoice_number']} — Ban Yaro",
html=body_html,
plain=plain,
attachments=[{"filename": filename, "content": pdf_bytes, "content_type": "application/pdf"}],
)
except Exception as e:
logger.error(f"Storno-Mail fehlgeschlagen: {e}")
return invoice

View file

@ -2,7 +2,6 @@
import io import io
import os import os
import re
import uuid import uuid
from typing import Optional from typing import Optional
@ -29,8 +28,6 @@ class ProfileUpdate(BaseModel):
notes_ki_enabled: Optional[int] = None notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None preferred_theme: Optional[str] = None
billing_address: Optional[str] = None
geburtstag: Optional[str] = None
def _load_user(user_id: int) -> dict: def _load_user(user_id: int) -> dict:
@ -38,8 +35,7 @@ def _load_user(user_id: int) -> dict:
row = conn.execute( row = conn.execute(
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified, """SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link, bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at, billing_address, profil_sichtbarkeit, avatar_url, created_at
geburtstag
FROM users WHERE id=?""", FROM users WHERE id=?""",
(user_id,) (user_id,)
).fetchone() ).fetchone()
@ -67,9 +63,6 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.") raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
if "social_link" in fields and len(fields["social_link"]) > 120: if "social_link" in fields and len(fields["social_link"]) > 120:
raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.") raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.")
if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).")
if not fields: if not fields:
return _load_user(user["id"]) return _load_user(user["id"])

View file

@ -30,24 +30,19 @@ async def public_stats():
if _pub_cache["data"] and now - _pub_cache["ts"] < _PUB_TTL: if _pub_cache["data"] and now - _pub_cache["ts"] < _PUB_TTL:
return _pub_cache["data"] return _pub_cache["data"]
with db() as conn: with db() as conn:
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
km = conn.execute( km = conn.execute(
# Alle Routen (öffentlich + privat), nur valide Aufzeichnungen "SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes"
"SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes WHERE is_valid=1"
).fetchone()[0]
posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0]
diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
kotbeutel = conn.execute(
"SELECT COUNT(*) FROM osm_pois WHERE type='waste_basket'"
).fetchone()[0] ).fetchone()[0]
posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0]
diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
data = { data = {
"users": users, "users": users,
"dogs": dogs, "dogs": dogs,
"km": int(km or 0), "km": int(km or 0),
"forum_posts": posts, "forum_posts": posts,
"diary_entries": diary, "diary_entries": diary,
"kotbeutel": kotbeutel,
} }
_pub_cache["data"] = data _pub_cache["data"] = data
_pub_cache["ts"] = now _pub_cache["ts"] = now

View file

@ -323,10 +323,10 @@ class SessionCreate(BaseModel):
datum: Optional[str] = None datum: Optional[str] = None
wiederholungen: int = 1 wiederholungen: int = 1
erfolgsquote: int = 50 erfolgsquote: int = 50
hund_stimmung: Optional[str] = "aufmerksam" hund_stimmung: str = "aufmerksam"
zufriedenheit: Optional[int] = 3 zufriedenheit: int = 3
notiz: Optional[str] = None notiz: Optional[str] = None
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll tagebuch_eintrag: bool = False
@router.post("/sessions") @router.post("/sessions")
@ -363,6 +363,42 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
# Badges prüfen # Badges prüfen
new_badges = _check_badges(conn, uid, dog_name) new_badges = _check_badges(conn, uid, dog_name)
# Tagebucheintrag erstellen?
diary_entry_id = None
if body.tagebuch_eintrag or ist_top:
stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung)
if ist_top:
titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!"
else:
titel = f"\U0001f3af Training: {body.exercise_name}"
text_parts = [
f"{body.wiederholungen} Wiederholungen \u00b7 "
f"Erfolgsquote: {body.erfolgsquote}% \u00b7 "
f"Stimmung: {stimmung_label}"
]
if body.notiz:
text_parts.append(f"\n\n{body.notiz}")
eintrag_text = "".join(text_parts)
diary_cur = conn.execute(
"""
INSERT INTO diary (dog_id, datum, typ, titel, text)
VALUES (?,?,?,?,?)
""",
(body.dog_id, datum, "training", titel, eintrag_text)
)
diary_entry_id = diary_cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(diary_entry_id, body.dog_id)
)
conn.execute(
"UPDATE training_sessions SET diary_entry_id=? WHERE id=?",
(diary_entry_id, session_id)
)
session = { session = {
"id": session_id, "id": session_id,
"user_id": uid, "user_id": uid,
@ -376,12 +412,14 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
"zufriedenheit": body.zufriedenheit, "zufriedenheit": body.zufriedenheit,
"notiz": body.notiz, "notiz": body.notiz,
"ist_top": bool(ist_top), "ist_top": bool(ist_top),
"diary_entry_id": diary_entry_id,
} }
return { return {
"session": session, "session": session,
"ist_top": bool(ist_top), "ist_top": bool(ist_top),
"badges": new_badges, "badges": new_badges,
"diary_entry_id": diary_entry_id,
} }

View file

@ -195,13 +195,6 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
_scheduler.add_job(
_job_invoice_reminder,
CronTrigger(hour=8, minute=30), # täglich 08:30 Uhr
id="invoice_reminder",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start() _scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).") logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).")
@ -214,278 +207,6 @@ def stop():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# JOB: Abo-Ablauf prüfen (täglich 03:00) # JOB: Abo-Ablauf prüfen (täglich 03:00)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
_TIER_PRICE = {"pro": 29.00, "breeder": 49.00}
async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: str):
"""Legt einen Rechnungs-Entwurf für die Abo-Verlängerung an, sofern noch keiner existiert."""
import os
from mailer import send_email, email_html
from routes.invoices import _next_invoice_number
# Gekündigte Abos bekommen keine Erneuerungsrechnung
if user.get("subscription_cancelled_at"):
logger.info(f"Kein Erneuerungsentwurf für {user['email']} — Abo ist gekündigt.")
return
tier = user["subscription_tier"]
price = _TIER_PRICE.get(tier, 29.00)
# Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr
start = expires + timedelta(days=1)
end = start.replace(year=start.year + 1) - timedelta(days=1)
period = f"{start.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}"
with db() as conn:
# Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum
existing = conn.execute(
"""SELECT id FROM invoices
WHERE user_id=? AND status IN ('draft','sent')
AND service_period=?""",
(user["id"], period)
).fetchone()
if existing:
logger.info(f"Erneuerungsrechnung bereits vorhanden für user {user['id']}")
return
# Billing-Adresse des Users laden
row = conn.execute(
"SELECT billing_address FROM users WHERE id=?", (user["id"],)
).fetchone()
billing_address = row["billing_address"] if row else None
# Rabatt berechnen (inline, da kein Admin-Import möglich)
disc_row = conn.execute(
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM users u WHERE u.id=?""",
(user["id"],)
).fetchone()
discount_pct = 0
discount_reason = None
referral_count = 0
if disc_row:
referral_count = disc_row["referral_count"]
if disc_row["is_founder"] or disc_row["is_founder_pending"]:
discount_pct = 100
discount_reason = "founder"
elif (disc_row["referred_by"] or 0) > 0:
ref = conn.execute(
"SELECT is_founder, is_founder_pending FROM users WHERE id=?",
(disc_row["referred_by"],)
).fetchone()
if ref and (ref["is_founder"] or ref["is_founder_pending"]):
discount_pct = 50
discount_reason = "referred_by_founder"
if not discount_reason:
for thr, pct in [(50, 50), (20, 30), (10, 20)]:
if referral_count >= thr:
discount_pct = pct
discount_reason = "referral"
break
discount_amt = round(price * discount_pct / 100, 2)
after_disc = round(price - discount_amt, 2)
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
if discount_reason == "founder":
notes = f"Gründer-Sonderkonditionen: {tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
elif discount_reason == "referred_by_founder":
notes = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
elif discount_reason == "referral":
notes = f"Herzlichen Dank für deine Unterstützung! Für {referral_count} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
else:
notes = f"{_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
invoice_number = _next_invoice_number(conn)
description = f"{tier_label} Jahresabo (Verlängerung)"
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
""", (
invoice_number, user["id"], user["name"], user["email"], billing_address,
description, period,
price, discount_pct, discount_amt, after_disc, after_disc, notes,
))
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
(conn.execute("SELECT last_insert_rowid()").fetchone()[0], description, price, price)
)
logger.info(f"Erneuerungsrechnung {invoice_number} als Entwurf angelegt für {user['email']}")
# Admin-Benachrichtigung
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
app_url = os.getenv("APP_URL", "https://banyaro.app")
body = f"""
<p>Für <strong>{user['name']}</strong> ({user['email']}) wurde automatisch ein
Rechnungsentwurf für die Abo-Verlängerung erstellt.</p>
<table style="border-collapse:collapse;font-size:14px;margin:12px 0">
<tr><td style="padding:4px 12px 4px 0;color:#888">Rechnung:</td><td><strong>{invoice_number}</strong></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Tarif:</td><td>{tier_label}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Betrag:</td><td>{price:.2f} EUR</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Zeitraum:</td><td>{period}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Abo läuft ab:</td><td>{expires.strftime('%d.%m.%Y')} (in 30 Tagen)</td></tr>
</table>
<p>Bitte prüfen, ggf. anpassen und rechtzeitig versenden.</p>"""
html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Zur Rechnung im Admin")
await send_email(
admin_email,
f"Erneuerungsrechnung {invoice_number} bereit — {user['name']}",
html,
f"Entwurf {invoice_number} für {user['name']} ({tier_label}, {price:.2f} EUR, {period}) bereit."
)
async def _remind_renewal_invoice(user: dict, expires: date, tier_label: str):
"""7-Tage-Erinnerung an René: Entwurf noch nicht versendet."""
import os
from mailer import send_email, email_html
with db() as conn:
draft = conn.execute(
"SELECT invoice_number FROM invoices WHERE user_id=? AND status='draft' LIMIT 1",
(user["id"],)
).fetchone()
if not draft:
return # kein offener Entwurf, nichts zu erinnern
admin_email = os.getenv("ADMIN_EMAIL", "")
if not admin_email:
return
app_url = os.getenv("APP_URL", "https://banyaro.app")
body = f"""
<p><strong>Achtung:</strong> Das Abo von <strong>{user['name']}</strong> ({user['email']})
läuft in <strong>7 Tagen</strong> (am {expires.strftime('%d.%m.%Y')}) ab.</p>
<p>Rechnungsentwurf <strong>{draft['invoice_number']}</strong> wurde noch nicht versendet.
Bitte jetzt versenden damit der Kunde rechtzeitig bezahlen kann.</p>"""
html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Rechnung jetzt senden")
await send_email(
admin_email,
f"⚠ Noch 7 Tage — Erneuerungsrechnung {draft['invoice_number']} nicht versendet",
html,
f"Entwurf {draft['invoice_number']} für {user['name']} noch nicht versendet. Abo läuft in 7 Tagen ab."
)
logger.info(f"7-Tage-Erinnerung an Admin für {user['email']}: {draft['invoice_number']}")
async def _job_invoice_reminder():
"""
Unbezahlte Rechnungen (status='sent'):
- Nach 21 Tagen: Zahlungsmahnung mit 14-Tage-Frist (§286/314 BGB)
- Nach 35 Tagen (21+14): Fristlose Abo-Kündigung
"""
from database import db as _db
from mailer import send_email, email_html
from routes.invoices import _next_invoice_number
import html as _html
import os
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
IBAN = os.getenv("RECHNUNG_IBAN", "")
ADMIN_MAIL = os.getenv("ADMIN_EMAIL", "")
today = datetime.now(_TZ).date()
with db() as conn:
open_invoices = conn.execute(
"""SELECT i.*, u.name AS user_name, u.subscription_tier, u.id AS uid
FROM invoices i
LEFT JOIN users u ON u.id = i.user_id
WHERE i.status = 'sent'
AND i.sent_at IS NOT NULL"""
).fetchall()
for inv in open_invoices:
try:
sent_date = datetime.fromisoformat(inv["sent_at"].replace("Z", "+00:00")).date()
days_open = (today - sent_date).days
rg = inv["invoice_number"]
name = inv["recipient_name"]
email = inv["recipient_email"]
amount = inv["amount_gross"]
frist = (today + timedelta(days=14)).strftime("%d.%m.%Y")
# ── 21 Tage: Zahlungsmahnung mit 14-Tage-Frist ───────────
if days_open == 21:
iban_line = f"<p style='margin:0 0 8px;font-size:13px'>IBAN: <strong>{IBAN}</strong> · Verwendungszweck: {rg}</p>" if IBAN else ""
body = f"""
<p style='margin:0 0 12px'>Hallo <b>{_html.escape(name)}</b>,</p>
<p style='margin:0 0 12px'>
unsere Rechnung <b>{rg}</b> vom {datetime.fromisoformat(inv['created_at'][:10]).strftime('%d.%m.%Y')}
über <b>{amount:.2f} EUR</b> ist leider noch offen.
</p>
<p style='margin:0 0 12px'>
Bitte überweisen Sie den Betrag bis zum <b>{frist}</b>.
{iban_line}
</p>
<p style='margin:0;font-size:13px;color:#888'>
Sollte die Zahlung bis zu diesem Datum nicht eingehen, sind wir leider gezwungen,
Ihr Abonnement fristlos zu kündigen (§&nbsp;314 BGB).
</p>"""
html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen")
await send_email(
email,
f"Zahlungserinnerung: Rechnung {rg} — Ban Yaro",
html,
f"Hallo {name},\n\nRechnung {rg} über {amount:.2f} EUR ist noch offen.\n"
f"Bitte bis {frist} überweisen. Andernfalls kündigen wir fristlos.\n"
+ (f"IBAN: {IBAN}, Verwendungszweck: {rg}\n" if IBAN else "")
)
logger.info(f"Zahlungsmahnung gesendet: {rg} an {email} (21 Tage offen)")
# ── 35 Tage: Fristlose Kündigung ─────────────────────────
elif days_open == 35:
# Abo kündigen wenn Nutzer zugeordnet und aktives Abo
if inv["uid"] and inv["subscription_tier"] not in (None, "standard", "standard_test"):
with db() as conn2:
conn2.execute(
"""UPDATE users SET subscription_tier='standard',
subscription_expires_at=NULL,
subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id=?""",
(inv["uid"],)
)
logger.info(f"Fristlose Kündigung: user {inv['uid']} wegen unbezahlter Rechnung {rg}")
body = f"""
<p style='margin:0 0 12px'>Hallo <b>{_html.escape(name)}</b>,</p>
<p style='margin:0 0 12px'>
da die Zahlung für Rechnung <b>{rg}</b> ({amount:.2f} EUR)
trotz unserer Zahlungserinnerung nicht eingegangen ist,
haben wir Ihr Abonnement gemäß §&nbsp;314 BGB fristlos gekündigt.
</p>
<p style='margin:0 0 12px'>
Ihre Daten bleiben vollständig erhalten. Sie können jederzeit ein neues Abonnement abschließen.
</p>
<p style='margin:0;font-size:13px;color:#888'>
Bei Rückfragen antworten Sie einfach auf diese E-Mail.
</p>"""
html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen")
await send_email(
email,
f"Ihr Ban Yaro Abonnement wurde gekündigt — Rechnung {rg}",
html,
f"Hallo {name},\n\nIhr Abo wurde wegen unbezahlter Rechnung {rg} fristlos gekündigt.\n"
f"Ihre Daten sind erhalten. Neue Buchung jederzeit möglich.\n"
)
if ADMIN_MAIL:
await send_email(
ADMIN_MAIL,
f"Fristlose Kündigung: {name}{rg} ({amount:.2f} EUR unbezahlt)",
email_html(f"<p>Abo von <b>{_html.escape(name)}</b> ({email}) wurde automatisch fristlos gekündigt (§314 BGB). Rechnung {rg} seit 35 Tagen offen.</p>"),
f"Abo {name} gekündigt wegen unbezahlter Rechnung {rg}."
)
except Exception as e:
logger.warning(f"Invoice-Reminder Fehler für {inv.get('invoice_number','?')}: {e}")
async def _job_subscription_check(): async def _job_subscription_check():
"""Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher.""" """Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher."""
from database import db as _db from database import db as _db
@ -496,8 +217,7 @@ async def _job_subscription_check():
with _db() as conn: with _db() as conn:
users = conn.execute( users = conn.execute(
"""SELECT id, name, email, subscription_tier, subscription_expires_at, """SELECT id, name, email, subscription_tier, subscription_expires_at
subscription_cancelled_at
FROM users FROM users
WHERE subscription_tier IN ('pro','breeder') WHERE subscription_tier IN ('pro','breeder')
AND subscription_expires_at IS NOT NULL""" AND subscription_expires_at IS NOT NULL"""
@ -533,7 +253,7 @@ async def _job_subscription_check():
await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html, await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html,
f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.") f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.")
# 30 Tage Warnung + Erneuerungsrechnung als Entwurf anlegen # 30 Tage Warnung
elif days_left == 30: elif days_left == 30:
body = f""" body = f"""
<p>Hallo {_html.escape(u['name'])},</p> <p>Hallo {_html.escape(u['name'])},</p>
@ -545,10 +265,7 @@ async def _job_subscription_check():
await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html, await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html,
f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).") f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).")
# Erneuerungsrechnung als Entwurf anlegen (nur wenn noch keine existiert) # 7 Tage Warnung
await _create_renewal_invoice_draft(u, expires, tier_label)
# 7 Tage — Warnung an User + Erinnerung an René falls Entwurf noch nicht versendet
elif days_left == 7: elif days_left == 7:
body = f""" body = f"""
<p>Hallo {_html.escape(u['name'])},</p> <p>Hallo {_html.escape(u['name'])},</p>
@ -558,7 +275,6 @@ async def _job_subscription_check():
html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern")
await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html, await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html,
f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.") f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.")
await _remind_renewal_invoice(u, expires, tier_label)
except Exception as e: except Exception as e:
logger.warning(f"subscription_check Fehler für {u['email']}: {e}") logger.warning(f"subscription_check Fehler für {u['email']}: {e}")

View file

@ -3087,8 +3087,8 @@ html.modal-open {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; } .map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
/* Zoom-Control: mittig zu beiden Filter-Reihen */ /* Zoom-Control und Filter-Tabs unter die Statusleiste schieben */
.map-full-layout .leaflet-top { padding-top: 30px; } .map-full-layout .leaflet-top { padding-top: 28px; }
} }
.map-full { width: 100%; height: 100%; } .map-full { width: 100%; height: 100%; }
@ -3137,7 +3137,8 @@ html.modal-open {
.map-legend-label { font-size: 10px; } .map-legend-label { font-size: 10px; }
.map-legend-all { .map-legend-all {
font-size: 1rem; font-size: 1rem;
padding: 5px 10px; min-width: 32px;
padding: 0 var(--space-2);
background: var(--c-surface-2); background: var(--c-surface-2);
border-color: var(--c-border); border-color: var(--c-border);
color: var(--c-text-secondary); color: var(--c-text-secondary);
@ -3149,73 +3150,6 @@ html.modal-open {
color: #fff; color: #fff;
} }
/* Dark-Mode: Karten-UI-Elemente (manuell + System) */
:root[data-theme="dark"] .map-legend-btn,
:root:not([data-theme="light"]) .map-legend-btn.dark-map {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root[data-theme="dark"] .map-legend-btn {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root[data-theme="dark"] .map-legend-btn.active {
background: var(--layer-color, var(--c-primary));
color: #fff;
border-color: var(--layer-color, var(--c-primary));
}
:root[data-theme="dark"] .map-legend-all {
background: rgba(36,28,20,0.92);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
:root[data-theme="dark"] .map-legend-all.all-off {
background: rgba(10,8,6,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.9);
}
:root[data-theme="dark"] .map-statusbar {
background: rgba(24,20,16,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7);
}
:root[data-theme="dark"] .leaflet-popup-content-wrapper,
:root[data-theme="dark"] .leaflet-popup-tip {
background: #241C14;
color: rgba(255,255,255,0.85);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .map-legend-btn {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root:not([data-theme="light"]) .map-legend-btn.active {
background: var(--layer-color, var(--c-primary));
color: #fff;
border-color: var(--layer-color, var(--c-primary));
}
:root:not([data-theme="light"]) .map-legend-all {
background: rgba(36,28,20,0.92);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
:root:not([data-theme="light"]) .map-statusbar {
background: rgba(24,20,16,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7);
}
:root:not([data-theme="light"]) .leaflet-popup-content-wrapper,
:root:not([data-theme="light"]) .leaflet-popup-tip {
background: #241C14;
color: rgba(255,255,255,0.85);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
}
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */ /* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
.map-fabs { .map-fabs {
position: absolute; position: absolute;
@ -5227,9 +5161,9 @@ html.modal-open {
/* "Stirbt der Hund?" Tags */ /* "Stirbt der Hund?" Tags */
.movie-tag-stirbt { .movie-tag-stirbt {
background: var(--c-danger-subtle); background: #fef2f2;
color: var(--c-danger); color: #dc2626;
border: 1.5px solid var(--c-danger-border); border: 1.5px solid #dc2626;
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
font-size: var(--text-xs); font-size: var(--text-xs);
@ -5239,9 +5173,9 @@ html.modal-open {
} }
.movie-tag-ueberlebt { .movie-tag-ueberlebt {
background: var(--c-success-subtle); background: #f0fdf4;
color: var(--c-success); color: #16a34a;
border: 1.5px solid color-mix(in srgb, var(--c-success) 30%, transparent); border: 1.5px solid #16a34a;
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
font-size: var(--text-xs); font-size: var(--text-xs);
@ -7942,84 +7876,77 @@ svg.empty-state-icon {
} }
#wp-welt { overflow: hidden; position: relative; } #wp-welt { overflow: hidden; position: relative; }
/* Navigation-Punkte — auf Mobile ausgeblendet, Labels übernehmen */ /* Navigation-Punkte */
#world-dots { #world-dots {
display: none;
}
/* Welt-Labels — jetzt unten als Tab-Bar */
#world-labels {
position: fixed; position: fixed;
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px); top: calc(env(safe-area-inset-top, 0px) + 14px);
top: auto;
left: 0; right: 0; left: 0; right: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 8px; gap: 5px;
z-index: 60;
pointer-events: none;
}
.wdot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--c-text);
opacity: 0.2;
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
}
.wdot.active {
width: 22px;
border-radius: 3px;
opacity: 1;
background: var(--c-primary);
}
/* Welt-Labels */
#world-labels {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 28px);
left: 0; right: 0;
display: flex;
justify-content: center;
gap: 28px;
z-index: 59; z-index: 59;
pointer-events: none; pointer-events: none;
} }
.wlabel { .wlabel {
font-size: 10px; font-size: 9px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.1em; letter-spacing: 0.12em;
color: white; color: white;
opacity: 0.45; opacity: 0.4;
text-transform: uppercase; text-transform: uppercase;
transition: opacity 0.18s, background 0.18s; transition: opacity 0.18s;
padding: 6px 16px;
border-radius: 20px;
}
.wlabel.active {
opacity: 1;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
} }
.wlabel.active { opacity: 1; }
@media (min-width: 768px) { @media (min-width: 768px) {
/* Desktop: Nav bleibt unten — nur Abstände anpassen */
.world-panel {
padding-top: calc(env(safe-area-inset-top, 0px) + 48px);
}
/* Top-Bereich (Greeting + Wetter/Route/Übung) zentriert und begrenzt */
.world-top {
max-width: 860px;
margin-left: auto;
margin-right: auto;
width: 100%;
}
/* Alle Chips in einer Zeile, zentriert, egal wie viele aktiv */
.world-chips-grid {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
justify-content: center !important;
grid-template-columns: unset !important;
max-width: none !important;
margin: 0 !important;
gap: 7px !important;
}
.world-chip {
flex: 0 1 80px !important;
min-width: 60px !important;
width: 80px !important;
height: 74px !important;
}
/* Nav vertikal zentriert zwischen Chips und Footer */
#world-labels { #world-labels {
gap: 24px; gap: 40px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px); top: calc(env(safe-area-inset-top, 0px) + 18px);
} }
.wlabel { .wlabel {
font-size: 12px; font-size: 13px;
letter-spacing: 0.14em; letter-spacing: 0.18em;
padding: 7px 18px; opacity: 0.55;
padding: 6px 14px;
border-radius: 20px;
text-shadow: 0 1px 6px rgba(0,0,0,0.7);
transition: opacity 0.18s, background 0.18s;
} }
.wlabel:hover { .wlabel:hover {
opacity: 0.85; opacity: 0.85;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
} }
.wlabel.active {
opacity: 1;
background: rgba(255, 255, 255, 0.18);
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
}
} }
/* Settings-Button */ /* Settings-Button */
@ -8114,13 +8041,12 @@ svg.empty-state-icon {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; /* Info oben, Chips unten */ justify-content: space-between; /* Info oben, Chips unten */
padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px padding: calc(env(safe-area-inset-top, 0px) + 58px) 14px
calc(env(safe-area-inset-bottom, 0px) + 76px); calc(env(safe-area-inset-bottom, 0px) + 88px);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
position: relative; /* Anker für absolut positionierte Footer-Links */
} }
/* Content-Divs füllen den Panel und verteilen Top/Bottom */ /* Content-Divs füllen den Panel und verteilen Top/Bottom */
@ -8135,8 +8061,8 @@ svg.empty-state-icon {
/* Oberer Bereich: Info + Reminders */ /* Oberer Bereich: Info + Reminders */
.world-top { display: flex; flex-direction: column; gap: 10px; } .world-top { display: flex; flex-direction: column; gap: 10px; }
/* Unterer Bereich: Chips (Daumen-Zone) — kompakt, ganz unten */ /* Unterer Bereich: Chips (Daumen-Zone) */
.world-bottom { display: flex; flex-direction: column; gap: 5px; } .world-bottom { display: flex; flex-direction: column; gap: 8px; }
/* Frosted-Glass Info-Card (oben in jeder Welt) */ /* Frosted-Glass Info-Card (oben in jeder Welt) */
.world-info-card { .world-info-card {
@ -8181,8 +8107,9 @@ svg.empty-state-icon {
.world-chips-grid { .world-chips-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 74px; grid-auto-rows: 80px; /* alle Chips gleich hoch */
gap: 7px; gap: 8px;
margin-top: auto;
} }
/* Einzelner Chip: Frosted Glass */ /* Einzelner Chip: Frosted Glass */
@ -8203,8 +8130,7 @@ svg.empty-state-icon {
transition: background 0.12s, transform 0.1s; transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
height: 74px; min-height: 80px; /* alle Chips gleich hoch */
overflow: hidden;
} }
.world-chip:active { .world-chip:active {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
@ -8216,7 +8142,7 @@ svg.empty-state-icon {
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
line-height: 1.2; line-height: 1.2;
max-height: 24px; /* 2 Zeilen bei 10px — px statt em, damit iOS-Schriftgröße nicht skaliert */ max-height: 2.4em; /* max. 2 Zeilen */
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@ -8282,14 +8208,11 @@ svg.empty-state-icon {
/* Footer-Links (Impressum / Die 100 / Datenschutz) */ /* Footer-Links (Impressum / Die 100 / Datenschutz) */
.world-footer-links { .world-footer-links {
position: absolute;
bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
left: 0; right: 0;
text-align: center; text-align: center;
padding: 0; padding: 10px 0 2px;
} }
.world-footer-links span { .world-footer-links span {
font-size: 10px; font-size: 11px;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
cursor: pointer; cursor: pointer;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@ -8723,34 +8646,6 @@ svg.empty-state-icon {
font-weight: 600; font-weight: 600;
} }
/* ----------------------------------------------------------
W3-Overlays Desktop: zentrierte Dialogs statt Bottom-Sheets
---------------------------------------------------------- */
@media (min-width: 768px) {
.w3-sheet-overlay {
justify-content: center;
align-items: center;
}
.w3-backdrop {
backdrop-filter: blur(4px);
}
.w3-sheet-panel {
border-radius: 20px;
width: 90%;
max-width: 480px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.w3-sheet-panel--scroll {
max-width: 680px;
max-height: 80vh;
}
/* all-chips Grid auf Desktop: auto-fill statt repeat(4,1fr) */
.w3-sheet-panel--scroll [style*="grid-template-columns:repeat(4,1fr)"] {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)) !important;
}
}
/* ---------------------------------------------------------- /* ----------------------------------------------------------
Settings / Dog-Profile: Card-Sektion-Header Settings / Dog-Profile: Card-Sektion-Header
(uppercase Label mit Border-Bottom) (uppercase Label mit Border-Bottom)

View file

@ -17,7 +17,6 @@
/* Oberflächen — Warmweiß und Strohbeige */ /* Oberflächen — Warmweiß und Strohbeige */
--c-bg: #FAF7F2; --c-bg: #FAF7F2;
--c-bg-secondary: #F2EDE4; /* Karten auf Seitenhintergrund */
--c-surface: #FFFFFF; --c-surface: #FFFFFF;
--c-surface-2: #EDE5D4; --c-surface-2: #EDE5D4;
--c-surface-3: #DDD0BB; --c-surface-3: #DDD0BB;
@ -123,7 +122,6 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="dark"]) { :root:not([data-theme="light"]):not([data-theme="dark"]) {
--c-bg: #1A1410; --c-bg: #1A1410;
--c-bg-secondary: #221A12; /* Karten auf Seitenhintergrund (dunkler) */
--c-surface: #241C14; --c-surface: #241C14;
--c-surface-2: #2E2418; --c-surface-2: #2E2418;
--c-surface-3: #3A2E20; --c-surface-3: #3A2E20;
@ -160,7 +158,6 @@
/* Manuelles Dark-Theme via data-theme="dark" (überschreibt auch prefers-color-scheme: light) */ /* Manuelles Dark-Theme via data-theme="dark" (überschreibt auch prefers-color-scheme: light) */
:root[data-theme="dark"] { :root[data-theme="dark"] {
--c-bg: #1A1410; --c-bg: #1A1410;
--c-bg-secondary: #221A12;
--c-surface: #241C14; --c-surface: #241C14;
--c-surface-2: #2E2418; --c-surface-2: #2E2418;
--c-surface-3: #3A2E20; --c-surface-3: #3A2E20;
@ -189,16 +186,6 @@
--shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.50), 0 8px 16px rgba(0, 0, 0, 0.35); --shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.50), 0 8px 16px rgba(0, 0, 0, 0.35);
} }
/* Global Dark-Mode für alle Leaflet-Karten (map, walks, lost, poison, forum, routes …) */
:root[data-theme="dark"] .leaflet-tile-pane {
filter: invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .leaflet-tile-pane {
filter: invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85);
}
}
/* ------------------------------------------------------------ /* ------------------------------------------------------------
2. RESET & BASE 2. RESET & BASE
------------------------------------------------------------ */ ------------------------------------------------------------ */

View file

@ -535,20 +535,6 @@
margin: 0 auto; margin: 0 auto;
} }
/* Desktop: Standard-Container auf 860px erweitern (768px1023px) */
@media (min-width: 768px) {
.page-container { max-width: 860px; }
}
/* Desktop-Breite: von app.js nach Page-Init gesetzt */
@media (min-width: 768px) {
.pc-desktop {
max-width: 860px !important;
margin-left: auto !important;
margin-right: auto !important;
}
}
/* Wide-Layout für Karte und ähnliches */ /* Wide-Layout für Karte und ähnliches */
.page-container-wide { .page-container-wide {
width: 100%; width: 100%;
@ -603,39 +589,8 @@
============================================================ */ ============================================================ */
@media (min-width: 1024px) { @media (min-width: 1024px) {
/* Admin: breit + Sidebar-Layout */ /* Etwas breiterer Standard-Container auf großen Screens */
#page-admin .page-container { max-width: 1200px; } .page-container { max-width: 860px; }
#page-admin .adm-shell {
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
#page-admin .adm-tabs {
display: flex !important;
flex-direction: column;
width: 190px;
flex-shrink: 0;
gap: var(--space-1);
padding-bottom: 0;
position: sticky;
top: var(--space-3);
}
#page-admin .adm-tabs .by-tab {
justify-content: flex-start !important;
text-align: left !important;
padding-left: var(--space-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#page-admin #adm-content {
flex: 1;
min-width: 0;
}
/* ---------------------------------------------------------- /* ----------------------------------------------------------
WELCOME: 2-spaltige Feature-Sections, zentrierter Hero WELCOME: 2-spaltige Feature-Sections, zentrierter Hero
@ -733,7 +688,7 @@
white-space: nowrap; white-space: nowrap;
} }
/* Admin: Tabs als 2-zeiliges Grid (Mobile/Tablet) */ /* Admin: Tabs auf 2 Zeilen */
#page-admin .adm-tabs { #page-admin .adm-tabs {
display: grid; display: grid;
grid-template-columns: repeat(var(--adm-tab-cols, 4), minmax(0, 1fr)); grid-template-columns: repeat(var(--adm-tab-cols, 4), minmax(0, 1fr));

View file

@ -101,9 +101,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1070"> <link rel="stylesheet" href="/css/design-system.css?v=907">
<link rel="stylesheet" href="/css/layout.css?v=1070"> <link rel="stylesheet" href="/css/layout.css?v=907">
<link rel="stylesheet" href="/css/components.css?v=1070"> <link rel="stylesheet" href="/css/components.css?v=907">
</head> </head>
<body> <body>
@ -296,7 +296,6 @@
<div style="display:flex;gap:var(--space-3);justify-content:center"> <div style="display:flex;gap:var(--space-3);justify-content:center">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span> <span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span> <span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
<span data-page="agb" style="cursor:pointer;text-decoration:underline">AGB</span>
</div> </div>
<div style="display:flex;justify-content:center"> <div style="display:flex;justify-content:center">
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs); <span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
@ -460,10 +459,6 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-breeder-editor">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-social"> <section class="page" id="page-social">
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
@ -492,10 +487,6 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-agb">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-widget"> <section class="page" id="page-widget">
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
@ -512,14 +503,6 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-partner">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-partner-profil">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-jobs"> <section class="page" id="page-jobs">
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
@ -616,10 +599,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1070"></script> <script src="/js/api.js?v=919"></script>
<script src="/js/ui.js?v=1070"></script> <script src="/js/ui.js?v=919"></script>
<script src="/js/app.js?v=1070"></script> <script src="/js/app.js?v=919"></script>
<script src="/js/worlds.js?v=1070"></script> <script src="/js/worlds.js?v=919"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -637,16 +620,6 @@
} }
window.addEventListener('offline', function() { window.addEventListener('offline', function() {
_updateBanner(); _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 // Queue-Count abfragen
if (navigator.serviceWorker) { if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) { navigator.serviceWorker.ready.then(function(reg) {
@ -705,7 +678,7 @@
// Backup: controllerchange (falls updatefound nicht feuert) // Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL) // NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert // — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!window._BY_SW_RELOAD) { if (!location.search.includes('_t=')) {
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) { if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload'); sessionStorage.removeItem('by_skip_sw_reload');

View file

@ -3,13 +3,11 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen. // Cache-Bust-Parameter nach Update-Reload sofort entfernen
// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach. if (location.search.includes('_t=')) history.replaceState(null, '', '/');
window._BY_SW_RELOAD = location.search.includes('_t=');
if (window._BY_SW_RELOAD) history.replaceState(null, '', '/');
const App = (() => { const App = (() => {
@ -66,19 +64,15 @@ const App = (() => {
moderation: { title: 'Moderation', module: null, requiresAuth: true }, moderation: { title: 'Moderation', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null }, impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null }, datenschutz: { title: 'Datenschutz', module: null },
agb: { title: 'AGB', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true }, widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
breeder: { title: 'Züchter-Profil', module: null }, breeder: { title: 'Züchter-Profil', module: null },
'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true }, litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null }, wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true }, laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null }, 'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', 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 }, jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true }, expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null }, recalls: { title: 'Rückrufe', module: null },
@ -261,8 +255,6 @@ const App = (() => {
if (mod?.init) { if (mod?.init) {
await mod.init(container, state, params); await mod.init(container, state, params);
page.module = mod; page.module = mod;
// Desktop: erste Inhalts-Div auf Standardbreite setzen
_applyDesktopWidth(pageId, container);
} else { } else {
// Platzhalter wenn Seite noch nicht gebaut // Platzhalter wenn Seite noch nicht gebaut
container.innerHTML = UI.emptyState({ container.innerHTML = UI.emptyState({
@ -273,13 +265,10 @@ const App = (() => {
page.module = {}; // verhindert erneutes Laden page.module = {}; // verhindert erneutes Laden
} }
} catch { } catch {
const _offline = !navigator.onLine;
container.innerHTML = UI.emptyState({ container.innerHTML = UI.emptyState({
icon: _offline ? '📡' : '🚧', icon: '🚧',
title: pages[pageId].title, title: pages[pageId].title,
text: _offline text: 'Diese Seite ist noch in Entwicklung.',
? '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 = {}; page.module = {};
} finally { } finally {
@ -287,23 +276,6 @@ const App = (() => {
} }
} }
// ----------------------------------------------------------
// DESKTOP WIDTH — einheitliche Breite auf großen Screens
// ----------------------------------------------------------
const _FULLSCREEN_PAGES = new Set([
'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse',
'routes','walks','litters','zucht-profil','widget',
]);
function _applyDesktopWidth(pageId, container) {
if (window.innerWidth < 768) return;
if (_FULLSCREEN_PAGES.has(pageId)) return;
const first = container.querySelector(':scope > div');
if (first && !first.classList.contains('page-container') &&
!first.classList.contains('pc-desktop')) {
first.classList.add('pc-desktop');
}
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// LOGIN GATE — wird statt Seiteninhalt angezeigt // LOGIN GATE — wird statt Seiteninhalt angezeigt
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -613,16 +585,11 @@ const App = (() => {
_checkNearbyAlerts(); _checkNearbyAlerts();
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
setInterval(_checkNearbyAlerts, 5 * 60_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', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
_checkNearbyAlerts(); _checkNearbyAlerts();
_sendHeartbeat();
if (state.page === 'chat') { if (state.page === 'chat') {
pages['chat']?.module?.refresh?.(); pages['chat']?.module?.refresh?.();
} }
@ -1173,21 +1140,6 @@ const App = (() => {
window.App = App; // Worlds kann App.navigate() aufrufen window.App = App; // Worlds kann App.navigate() aufrufen
// App starten // 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', () => { document.addEventListener('DOMContentLoaded', () => {
App.init(); App.init();
if (IS_STAGING) { if (IS_STAGING) {

View file

@ -27,7 +27,6 @@ window.Page_admin = (() => {
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
]; ];
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -56,17 +55,17 @@ window.Page_admin = (() => {
<!-- Action Items --> <!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div> <div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Sidebar + Content (Desktop: nebeneinander) --> <!-- Tabs -->
<div class="adm-shell"> <div class="by-tabs adm-tabs" id="adm-tabs">
<div class="by-tabs adm-tabs" id="adm-tabs"> ${TABS.map(t => `
${TABS.map(t => ` <button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}"> ${UI.icon(t.icon)} ${t.label}
${UI.icon(t.icon)} ${t.label} </button>
</button> `).join('')}
`).join('')}
</div>
<div id="adm-content"></div>
</div> </div>
<!-- Inhalt -->
<div id="adm-content"></div>
`; `;
_container.querySelector('#adm-tabs') _container.querySelector('#adm-tabs')
@ -98,7 +97,6 @@ window.Page_admin = (() => {
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, { 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); const open = items.filter(i => d[i.key] > 0);
@ -168,7 +166,6 @@ window.Page_admin = (() => {
case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break; case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break; case 'upgrades': await _renderUpgrades(el); break;
case 'rechnungen': await _renderRechnungen(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -3531,14 +3528,8 @@ window.Page_admin = (() => {
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
${tierBadge(r.tier)} ${tierBadge(r.tier)}
${r.discount_pct > 0 ? `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:#e67e22;color:#fff;margin-left:4px">
${r.discount_pct}% Rabatt</span>` : ''}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span> <span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
</div> </div>
${r.discount_reason === 'founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Gründer — kostenfrei</div>` : ''}
${r.discount_reason === 'referred_by_founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Von Gründer eingeladen</div>` : ''}
${r.discount_reason === 'referral' ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">${r.referral_count} Freunde geworben</div>` : ''}
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary); ${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04))"> background:var(--c-surface-raised,rgba(0,0,0,.04))">
@ -3546,25 +3537,12 @@ window.Page_admin = (() => {
</div>` : ''} </div>` : ''}
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)"> <button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
<button class="btn adm-invoice-btn" style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}" padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}" cursor:pointer;font-size:var(--text-sm);font-weight:600">
data-discount="${r.discount_pct || 0}" Freischalten
data-discount-reason="${r.discount_reason || ''}" </button>
data-referral-count="${r.referral_count || 0}"
style="background:#e67e22;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung erstellen
</button>
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
Freischalten
</button>
</div>
</div>`; </div>`;
// Erledigte als kompakte Tabellenzeilen // Erledigte als kompakte Tabellenzeilen
@ -3610,7 +3588,7 @@ window.Page_admin = (() => {
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier; const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title: `${name} auf ${tierLabel} freischalten?`, title: `${name} auf ${tierLabel} freischalten?`,
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`, message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`,
confirmText: 'Freischalten', confirmText: 'Freischalten',
danger: false, danger: false,
}); });
@ -3619,14 +3597,7 @@ window.Page_admin = (() => {
btn.textContent = '…'; btn.textContent = '…';
try { try {
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
if (res.invoice_number) { UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
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(); _renderTab();
_renderActionItems(); _renderActionItems();
} catch (e) { } catch (e) {
@ -3636,867 +3607,6 @@ window.Page_admin = (() => {
} }
}); });
}); });
// "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
const TIER_ITEMS = {
pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
};
const _year = new Date().getFullYear();
const _now = new Date();
const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1);
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
const _period = `${_fmt(_now)} - ${_fmt(_end)}`;
function _discountNote(reason, count, pct, tierLabel) {
const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';
if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`;
if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`;
if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`;
return agb;
}
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
btn.addEventListener('click', () => {
const { name, email, tier, address } = btn.dataset;
const discountPct = Number(btn.dataset.discount) || 0;
const discountReason = btn.dataset.discountReason || '';
const referralCount = Number(btn.dataset.referralCount) || 0;
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
_openNeueRechnungModal(() => {
_tab = 'rechnungen';
_renderTab();
}, {
recipient_name: name,
recipient_email: email,
recipient_address: address || '',
service_period: _period,
discount_pct: discountPct,
notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description),
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
});
});
});
}
// ------------------------------------------------------------------
// TAB: RECHNUNGEN
// ------------------------------------------------------------------
async function _renderRechnungen(el) {
let _subView = 'liste'; // 'liste' | 'cashflow'
async function _load() {
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
${UI.icon('list-bullets')} Rechnungen
</button>
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
${UI.icon('chart-bar')} Cashflow
</button>
</div>
${_subView === 'liste' ? `
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
${UI.icon('plus')} Neue Rechnung
</button>` : ''}
</div>
<div id="adm-inv-content">
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade</div>
</div>
`;
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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
};
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(`<button class="btn btn-sm btn-ghost adm-inv-edit" data-id="${inv.id}" title="Bearbeiten">
${UI.icon('pencil')} Bearbeiten
</button>`);
actions.push(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Senden">
${UI.icon('paper-plane-tilt')} Senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Erneut senden"
style="color:var(--c-text-muted)">
${UI.icon('paper-plane-tilt')} Erneut senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
${UI.icon('check-circle')} Bezahlt
</button>`);
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}"
style="color:var(--c-danger)" title="Stornieren">
${UI.icon('x-circle')} Storno
</button>`);
}
if (inv.status === 'paid' || inv.status === 'cancelled') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
${UI.icon('eye')} Details
</button>`);
}
return `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
${_esc(inv.invoice_number)}
</td>
<td class="adm-td">
<div style="font-weight:500">${_esc(inv.recipient_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(inv.recipient_email || '')}</div>
</td>
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
${_fmtEur(inv.amount_gross)}
${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01
? `<div style="font-size:10px;color:var(--c-warning,#d97706);font-weight:500">
erhalten: ${_fmtEur(inv.paid_amount)}
${inv.paid_amount < inv.amount_gross
? `<span style="color:var(--c-danger)">-${_fmtEur(inv.amount_gross - inv.paid_amount)}</span>`
: ''}
</div>`
: ''}
</td>
<td class="adm-td">${_statusBadge(inv.status)}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${_fmtDate(inv.created_at)}
</td>
<td class="adm-td" style="white-space:nowrap">
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Nummer</th>
<th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th>
<th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
// 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
${!isEdit && !p.recipient_name ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6">
Diese Rechnung ist für <strong>sonstige Leistungen</strong> (Beratung, Einmalleistung etc.).<br>
Für Abo-Verlängerungen bitte den Button <strong>Rechnung erstellen"</strong> in der Upgrades-Liste verwenden.
</div>` : ''}
<!-- Empfänger -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
<input class="form-control" name="recipient_name" type="text" required
placeholder="Max Muster" value="${_esc(p.recipient_name || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
<input class="form-control" name="recipient_email" type="email"
placeholder="max@example.com" value="${_esc(p.recipient_email || '')}">
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Adresse
${p.recipient_name && !p.recipient_address
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
: '<span style="color:var(--c-text-muted)">(optional)</span>'}
</label>
<textarea class="form-control" name="recipient_address" rows="2"
placeholder="Musterstr. 1&#10;12345 Berlin"
style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}</textarea>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" name="service_period" type="text"
placeholder="z.B. 15.05.2026 oder einmalige Leistung"
value="${_esc(p.service_period || '')}">
</div>
<!-- Positionen -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
<button type="button" id="${id}-add-item"
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
+ Position hinzufügen
</button>
</div>
<div id="${id}-items" style="display:flex;flex-direction:column;gap:var(--space-2)">
<!-- Items werden dynamisch eingefügt -->
</div>
</div>
<!-- Rabatt -->
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="${p.discount_pct ?? 0}"
style="width:80px" id="${id}-discount">
</div>
<!-- Live-Vorschau -->
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
<span style="color:var(--c-text-muted)">Netto: </span>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
<textarea class="form-control" name="notes" rows="2"
style="resize:vertical;font-family:inherit"
placeholder="Interne Notiz / Zahlungshinweis">${_esc(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}</textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} ${isEdit ? 'Änderungen speichern' : 'Rechnung erstellen'}</button>
`,
});
// 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 = `
<input class="form-control inv-item-desc" type="text" placeholder="Beschreibung *"
value="${_esc(desc)}" style="font-size:var(--text-sm)">
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
style="font-size:var(--text-sm);text-align:right" title="Menge">
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
${UI.icon('x')}
</button>
`;
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 = `
<span style="color:var(--c-text-muted)">Netto: </span>
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} </strong>
${disc > 0 ? `&nbsp;·&nbsp;<span style="color:var(--c-danger)">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
&nbsp;·&nbsp;<span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} </span>
`;
}
// 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Zahlungsdatum *</label>
<input class="form-control" name="paid_at" type="date" value="${today}" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Eingegangener Betrag () *</label>
<input class="form-control" name="paid_amount" id="${id}-amt" type="number" min="0" step="0.01"
value="${defaultAmount.toFixed(2)}" required>
</div>
<div id="${id}-diff" style="display:none;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6"></div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
`,
});
// 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: <strong>-${diff.toFixed(2)} €</strong> weniger als fakturiert.<br>
<label style="display:flex;align-items:center;gap:6px;margin-top:4px;cursor:pointer">
<input type="checkbox" id="${id}-kulanz">
<span>Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)</span>
</label>`;
} else {
diffEl.innerHTML = `Überzahlung: <strong>+${(-diff).toFixed(2)} €</strong> 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Rechnung <strong>${_esc(invoiceNum)}</strong> stornieren.
</p>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Stornierungsgrund *</label>
<input class="form-control" name="reason" type="text" required
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit"
style="background:var(--c-danger);border-color:var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>
`,
});
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 => `
<tr>
<td style="padding:6px 8px">${_esc(item.description)}</td>
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
</tr>
`).join('');
UI.modal.open({
title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`,
body: `
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
<div style="font-weight:600">${_esc(inv.recipient_name)}</div>
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(inv.recipient_email)}</div>` : ''}
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${_esc(inv.recipient_address)}</div>` : ''}
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Erstellt: ${_fmtDate(inv.created_at)}<br>
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
</div>
</div>
</div>
${inv.service_period ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
<div>${_esc(inv.service_period)}</div>
</div>` : ''}
<!-- Positionen -->
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
<th style="text-align:right;padding:4px 8px">Menge</th>
<th style="text-align:right;padding:4px 8px">Preis</th>
<th style="text-align:right;padding:4px 8px">Gesamt</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid var(--c-border)">
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
</tr>
</tfoot>
</table>
</div>
${inv.notes ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-xs);white-space:pre-wrap">${_esc(inv.notes)}</div>
</div>` : ''}
</div>
`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
}
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]) => `
<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
</div>`).join('');
const monthRows = (cf.monthly || []).map((m, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td">${_esc(m.month)}</td>
<td class="adm-td" style="text-align:right">${m.count}</td>
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
</tr>`).join('');
// Quartalsbericht-Download
const currentYear = new Date().getFullYear();
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
el.innerHTML = `
<!-- Übersichtskacheln -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
</div>
${countKacheln}
</div>
<!-- Monatliche Tabelle -->
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Monat</th>
<th class="adm-th" style="text-align:right">Rechnungen</th>
<th class="adm-th" style="text-align:right">Umsatz</th>
</tr>
</thead>
<tbody>
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
</tbody>
</table>
</div>
</div>
<!-- Quartalsbericht -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
${UI.icon('file-csv')} Quartalsbericht herunterladen
</div>
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Jahr</label>
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Quartal</label>
<select id="adm-inv-quarter" class="form-control" style="width:auto">
<option value="1">Q1 (JanMär)</option>
<option value="2">Q2 (AprJun)</option>
<option value="3">Q3 (JulSep)</option>
<option value="4">Q4 (OktDez)</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
${UI.icon('download-simple')} CSV herunterladen
</button>
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
${UI.icon('eye')} Vorschau
</button>
</div>
<div id="adm-inv-q-result" style="margin-top:var(--space-3)"></div>
</div>
`;
// 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 = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) {
resultEl.innerHTML = `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
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)
? ` <span style="font-size:var(--text-xs);color:var(--c-text-muted)">(RG: ${_fmtE(inv.amount_gross)})</span>` : '';
return `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs);${isStorno?'color:var(--c-danger)':''}">${_esc(inv.invoice_number)}</td>
<td class="adm-td">${_esc(inv.recipient_name)}</td>
<td class="adm-td" style="text-align:right;font-weight:600;${amtColor}">${_fmtE(effectiveAmt)}${amtNote}</td>
<td class="adm-td" style="${isStorno?'color:var(--c-danger)':''}">${sL[inv.status]||inv.status}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtD(inv.created_at)}</td>
</tr>`;
}).join('');
resultEl.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
${_esc(data.period || `Q${q} ${year}`)} ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2)">
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th><th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
</tr></thead>
<tbody>${rows2}</tbody>
<tfoot><tr style="border-top:2px solid var(--c-border)">
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
<td colspan="2" class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
</td>
</tr></tfoot>
</table>
</div>`;
} catch (e) {
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${_esc(e.message)}</div>`;
}
});
} }
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -1,195 +0,0 @@
/* ============================================================
BAN YARO Allgemeine Geschäftsbedingungen
============================================================ */
window.Page_agb = (() => {
const S = {
h2: `font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-primary);margin:0 0 var(--space-2)`,
p: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0`,
ul: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:var(--space-2) 0 0;padding-left:var(--space-5)`,
a: `color:var(--c-primary)`,
};
function sec(title, body) {
return `
<section style="margin-bottom:var(--space-6)">
<h2 style="${S.h2}">${title}</h2>
${body}
</section>`;
}
function init(container) {
container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-6) var(--space-4)">
<h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">Allgemeine Geschäftsbedingungen</h1>
<p style="${S.p};margin-bottom:var(--space-6)">Gültig ab Mai 2026</p>
${sec('1. Geltungsbereich', `
<p style="${S.p}">
Diese AGB gelten für die Nutzung der Plattform <strong>Ban Yaro</strong>
(<a href="https://banyaro.app" style="${S.a}">banyaro.app</a>), betrieben von:<br><br>
René Degelmann<br>
Ringstr. 26, 85560 Ebersberg<br>
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>
<p style="${S.p};margin-top:var(--space-3)">
Sie gelten ausschließlich für kostenpflichtige Abonnements. Die kostenlose Nutzung
der App setzt lediglich die Registrierung voraus.
</p>`)}
${sec('2. Mindestalter', `
<p style="${S.p}">
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.
</p>`)}
${sec('3. Leistungen', `
<p style="${S.p}">Ban Yaro bietet folgende kostenpflichtige Abonnements an:</p>
<ul style="${S.ul}">
<li>
<strong>Ban Yaro Pro 29 EUR/Jahr:</strong> 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.
</li>
<li>
<strong>Ban Yaro Züchter 49 EUR/Jahr:</strong> Alle Pro-Funktionen plus
Zuchtkartei, Stammbaum, Wurfverwaltung und Züchterprofil.
</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
Änderungen am Funktionsumfang werden vorab per E-Mail angekündigt. Wesentliche
Leistungsminderungen berechtigen zur außerordentlichen Kündigung.
</p>`)}
${sec('4. Nutzungsregeln / Community', `
<p style="${S.p}">Die Nutzung der Plattform-Features (Forum, Chat, Fotos, Kommentare) unterliegt folgenden Regeln:</p>
<ul style="${S.ul}">
<li>Keine rechtswidrigen, beleidigenden, diskriminierenden oder irreführenden Inhalte</li>
<li>Kein Spam, keine Werbung ohne Genehmigung, keine Fake-Accounts</li>
<li>Respektvoller Umgang mit anderen Nutzern</li>
<li>Keine Verletzung von Urheberrechten Dritter bei hochgeladenen Inhalten</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
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
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
gerichtet werden.
</p>`)}
${sec('5. Nutzerinhalte und Lizenzen', `
<p style="${S.p}">
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.
</p>`)}
${sec('6. Preise und Zahlung', `
<p style="${S.p}">
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
<strong>14 Tagen</strong> nach Rechnungsstellung zu überweisen.
</p>
<p style="${S.p};margin-top:var(--space-3)">
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.
</p>`)}
${sec('7. Vertragslaufzeit und Kündigung', `
<p style="${S.p}">
Die Erstlaufzeit beträgt <strong>12 Monate</strong> ab dem Tag der Freischaltung.
Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit kündbar jederzeit
mit einer Frist von <strong>einem Monat zum Monatsende</strong> (§ 309 Nr. 9 BGB).
</p>
<p style="${S.p};margin-top:var(--space-3)">
Die Kündigung ist jederzeit in den App-Einstellungen unter
<strong>Einstellungen Abonnement Kündigen</strong> 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.
</p>`)}
${sec('8. Kein Erstattungsanspruch', `
<p style="${S.p}">
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.
</p>`)}
${sec('9. Widerrufsrecht', `
<p style="${S.p}">
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.
</p>`)}
${sec('10. Fristlose Kündigung durch den Anbieter', `
<p style="${S.p}">
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.
</p>`)}
${sec('11. KI-Funktionen / Haftung für KI-Inhalte', `
<p style="${S.p}">
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.
</p>`)}
${sec('12. Verfügbarkeit', `
<p style="${S.p}">
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.
</p>`)}
${sec('13. Änderungen dieser AGB', `
<p style="${S.p}">
Änderungen der AGB werden per <strong>E-Mail und in der App</strong> 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.
</p>`)}
${sec('14. Anwendbares Recht', `
<p style="${S.p}">
Es gilt ausschließlich <strong>deutsches Recht</strong>. 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).
</p>`)}
${sec('15. Kontakt', `
<p style="${S.p}">
René Degelmann<br>
Ringstr. 26, 85560 Ebersberg<br>
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 · Version 2
</p>
</div>
`;
}
function refresh() {}
return { init, refresh };
})();

View file

@ -32,26 +32,6 @@ window.Page_datenschutz = (() => {
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a> E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>`)} </p>`)}
${sec('Hosting &amp; Infrastruktur', `
<p style="${S.p}">
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.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Für den E-Mail-Versand (Kontobestätigung, Benachrichtigungen, Rechnungen) nutzen wir
<strong>Brevo</strong> (Sendinblue SAS, 55 rue d'Amsterdam, 75008 Paris, Frankreich).
Brevo ist nach EU-Standardvertragsklauseln zertifiziert. Dabei werden E-Mail-Adresse
und Name übermittelt. Datenschutzinformationen:
<a href="https://www.brevo.com/de/legal/privacypolicy/" target="_blank" rel="noopener"
style="${S.a}">brevo.com/de/legal/privacypolicy/</a>.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Für anonymisierte Nutzungsstatistiken betreiben wir <strong>Umami Analytics</strong>
auf unserem eigenen Server. Es werden keine personenbezogenen Daten oder IP-Adressen
gespeichert. Kein Tracking über Sitzungen hinweg.
</p>`)}
${sec('Deine Daten gehören dir', ` ${sec('Deine Daten gehören dir', `
<p style="${S.p}"> <p style="${S.p}">
Ban Yaro ist eine private Community-App. Dein <strong>Tagebuch</strong>, deine Ban Yaro ist eine private Community-App. Dein <strong>Tagebuch</strong>, deine
@ -90,9 +70,6 @@ window.Page_datenschutz = (() => {
Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft 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 widerrufen werden (Art. 7 Abs. 3 DSGVO) einfach die entsprechende Funktion in den
Einstellungen deaktivieren oder die Browser-Freigabe entziehen. Einstellungen deaktivieren oder die Browser-Freigabe entziehen.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Impressum und rechtliche Grundlage nach § 5 DDG (Digitale-Dienste-Gesetz).
</p>`)} </p>`)}
${sec('Datenweitergabe', ` ${sec('Datenweitergabe', `
@ -115,13 +92,6 @@ window.Page_datenschutz = (() => {
Du kannst Gespräche jederzeit selbst löschen. Du kannst Gespräche jederzeit selbst löschen.
</p>`)} </p>`)}
${sec('Moderation &amp; Community', `
<p style="${S.p}">
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).
</p>`)}
${sec('KI-Funktionen', ` ${sec('KI-Funktionen', `
<p style="${S.p}"> <p style="${S.p}">
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge, Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
@ -157,12 +127,6 @@ window.Page_datenschutz = (() => {
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung. KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO) Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
findet nicht statt. findet nicht statt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
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.
</p>`)} </p>`)}
${sec('Wetterdaten & Kartendienste', ` ${sec('Wetterdaten & Kartendienste', `
@ -215,16 +179,6 @@ window.Page_datenschutz = (() => {
style="${S.a}">openrouteservice.org/privacy-policy</a> style="${S.a}">openrouteservice.org/privacy-policy</a>
</p>`)} </p>`)}
${sec('Technische Speicherung', `
<p style="${S.p}">
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.
</p>`)}
${sec('Push-Benachrichtigungen', ` ${sec('Push-Benachrichtigungen', `
<p style="${S.p}"> <p style="${S.p}">
Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den
@ -279,28 +233,11 @@ window.Page_datenschutz = (() => {
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:<br> Beschwerde einzulegen:<br>
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br> <strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>
Promenade 18, 91522 Ansbach<br> Promenade 27, 91522 Ansbach<br>
<a href="mailto:poststelle@lda.bayern.de"
style="${S.a}">poststelle@lda.bayern.de</a> ·
<a href="https://www.lda.bayern.de" target="_blank" rel="noopener" <a href="https://www.lda.bayern.de" target="_blank" rel="noopener"
style="${S.a}">www.lda.bayern.de</a> style="${S.a}">www.lda.bayern.de</a>
</p>`)} </p>`)}
${sec('Zahlungsdaten', `
<p style="${S.p}">
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 <strong>10 Jahre</strong> aufbewahrt. Rechnungen werden per E-Mail mit
TLS-Verschlüsselung zugestellt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
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
<a href="#agb" style="${S.a}">AGB</a>.
</p>`)}
${sec('Speicherdauer', ` ${sec('Speicherdauer', `
<p style="${S.p}"> <p style="${S.p}">
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst
@ -309,14 +246,8 @@ window.Page_datenschutz = (() => {
Server-Logs werden nach 30 Tagen rotiert. Server-Logs werden nach 30 Tagen rotiert.
</p>`)} </p>`)}
${sec('Mindestalter', `
<p style="${S.p}">
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.
</p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0"> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 · Version 3 Stand: Mai 2026 · Version 2
</p> </p>
</div> </div>

View file

@ -6,8 +6,6 @@
window.Page_diary = (() => { window.Page_diary = (() => {
const _CACHE_KEY = 'by_diary_cache';
// ---------------------------------------------------------- // ----------------------------------------------------------
// MODUL-STATE // MODUL-STATE
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -326,7 +324,6 @@ window.Page_diary = (() => {
async function _load() { async function _load() {
const dog = _appState.activeDog; const dog = _appState.activeDog;
if (!dog) return; if (!dog) return;
const cacheKey = _CACHE_KEY + '_' + dog.id;
try { try {
const params = { limit: LIMIT, offset: _offset }; const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery; if (_searchQuery) params.q = _searchQuery;
@ -334,10 +331,6 @@ window.Page_diary = (() => {
const batch = await API.diary.list(dog.id, params); const batch = await API.diary.list(dog.id, params);
_entries = _entries.concat(batch); _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 // "Mehr laden" anzeigen wenn volle Page geladen wurde
const loadMore = _container.querySelector('#diary-load-more'); const loadMore = _container.querySelector('#diary-load-more');
if (loadMore) { if (loadMore) {
@ -346,17 +339,7 @@ window.Page_diary = (() => {
// Stats-Bar befüllen // Stats-Bar befüllen
_renderStatsBar(); _renderStatsBar();
} catch { } catch (err) {
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.'); UI.toast.error('Einträge konnten nicht geladen werden.');
} }
} }
@ -1765,7 +1748,6 @@ window.Page_diary = (() => {
UI.toast.success('Eintrag gespeichert.'); UI.toast.success('Eintrag gespeichert.');
} else { } else {
const created = await API.diary.create(_appState.activeDog.id, payload); const created = await API.diary.create(_appState.activeDog.id, payload);
if (created?._queued) { UI.modal.close(); return; }
if (_newFiles.length > 0) { if (_newFiles.length > 0) {
const { uploaded, exifGps } = await _uploadNewFiles(created.id); const { uploaded, exifGps } = await _uploadNewFiles(created.id);
created.media_items = uploaded; created.media_items = uploaded;

View file

@ -24,58 +24,12 @@ window.Page_impressum = (() => {
<section style="margin-bottom:var(--space-6)"> <section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold); <h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2> color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-4)"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
E-Mail: <a href="mailto:hallo@banyaro.app" E-Mail: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">hallo@banyaro.app</a><br> style="color:var(--c-primary)">hallo@banyaro.app</a><br>
Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden. Kontaktformular: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">Nachricht senden</a>
</p> </p>
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100"
placeholder="Dein Name"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<input id="cf-email" type="email" required maxlength="200"
placeholder="deine@email.de"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<input id="cf-subject" type="text" required maxlength="150"
placeholder="Worum geht es?"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<textarea id="cf-message" required maxlength="3000" rows="5"
placeholder="Deine Nachricht…"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);resize:vertical;
font-family:inherit;box-sizing:border-box"></textarea>
</div>
<div id="cf-status" style="display:none;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);font-size:var(--text-sm)"></div>
<button id="cf-submit" type="submit"
style="align-self:flex-start;padding:var(--space-2) var(--space-5);
border-radius:var(--radius-full);border:none;cursor:pointer;
background:var(--c-primary);color:#fff;font-size:var(--text-sm);
font-weight:600">
Nachricht senden
</button>
</form>
</section> </section>
<section style="margin-bottom:var(--space-6)"> <section style="margin-bottom:var(--space-6)">
@ -92,6 +46,9 @@ window.Page_impressum = (() => {
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold); <h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Streitschlichtung</h2> color:var(--c-text);margin:0 0 var(--space-2)">Streitschlichtung</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
<a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener"
style="color:var(--c-primary)">https://ec.europa.eu/consumers/odr</a>.<br>
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
</p> </p>
@ -104,73 +61,20 @@ window.Page_impressum = (() => {
Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, 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 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. Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir
die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer.
diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls
unverzüglich entfernt.
</p> </p>
</section> </section>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0"> <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 Stand: April 2026
</p> </p>
</div> </div>
`; `;
} }
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() {} function refresh() {}
return { return { init, refresh };
init(container) {
_origInit(container);
_initContactForm(container);
},
refresh
};
})(); })();

View file

@ -48,20 +48,20 @@ window.Page_laeufi = (() => {
</svg> </svg>
</div>`; </div>`;
return ` return `
<div id="breeder-private-header" style="background:var(--c-bg-secondary); <div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid var(--c-border); border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${logoHtml}
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2> text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256"> <svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </svg>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span> <span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -69,14 +69,14 @@ window.Page_laeufi = (() => {
function _render() { function _render() {
_container.innerHTML = ` _container.innerHTML = `
<div style="width:100%;max-width:860px;margin:0 auto;box-sizing:border-box"> <div style="max-width:860px">
${_privateHeader()} ${_privateHeader()}
<div class="by-toolbar" style="margin-bottom:var(--space-4);padding:0 var(--space-4)"> <div class="by-toolbar" style="margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)"> <h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit ${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
</h2> </h2>
</div> </div>
<div id="laeufi-list" style="padding:0 var(--space-4) var(--space-6)"> <div id="laeufi-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p> <p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div> </div>
</div>`; </div>`;

View file

@ -31,13 +31,13 @@ window.Page_litters = (() => {
function _statusBadge(status) { function _statusBadge(status) {
const map = { const map = {
geplant: { label: 'Geplant', cls: 'badge-warning' }, geplant: { label: 'Geplant', color: '#6B7280' },
geboren: { label: 'Geboren', cls: 'badge-primary' }, geboren: { label: 'Geboren', color: '#3B82F6' },
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' }, abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
}; };
const s = map[status] || { label: status, cls: 'badge-muted' }; const s = map[status] || { label: status, color: '#6B7280' };
return `<span class="badge ${s.cls}">${_esc(s.label)}</span>`; return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
} }
function _fmtDate(iso) { function _fmtDate(iso) {
@ -54,12 +54,12 @@ window.Page_litters = (() => {
function _puppyStatusBadge(status) { function _puppyStatusBadge(status) {
const map = { const map = {
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' }, verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
reserviert: { label: 'Reserviert', cls: 'badge-warning' }, reserviert: { label: 'Reserviert', color: '#F59E0B' },
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' }, abgegeben: { label: 'Abgegeben', color: '#6B7280' },
}; };
const s = map[status] || { label: status, cls: 'badge-muted' }; const s = map[status] || { label: status, color: '#9CA3AF' };
return `<span class="badge badge-sm ${s.cls}">${_esc(s.label)}</span>`; return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -113,20 +113,20 @@ window.Page_litters = (() => {
</svg> </svg>
</div>`; </div>`;
return ` return `
<div id="breeder-private-header" style="background:var(--c-bg-secondary); <div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid var(--c-border); border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${logoHtml}
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2> text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256"> <svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </svg>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span> <span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div> </div>
</div> </div>
</div>`; </div>`;

View file

@ -5,72 +5,17 @@
window.Page_lost = (() => { 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 // MODUL-STATE
// ---------------------------------------------------------- // ----------------------------------------------------------
let _container = null; let _container = null;
let _appState = null; let _appState = null;
let _map = null; let _map = null;
let _markers = []; let _markers = [];
let _userMarker = null; let _userMarker = null;
let _reports = []; let _reports = [];
let _userPos = null; let _userPos = null;
let _leafletLoaded = false; 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 // INIT
@ -168,7 +113,6 @@ window.Page_lost = (() => {
// KARTE INITIALISIEREN // KARTE INITIALISIEREN
// ---------------------------------------------------------- // ----------------------------------------------------------
function _initMap() { function _initMap() {
_injectStyles();
const mapEl = document.getElementById('lost-map'); const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return; if (!mapEl || !window.L || _map) return;
@ -236,23 +180,7 @@ window.Page_lost = (() => {
} }
try { try {
const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25); _reports = 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(); _renderMarkers();
_renderHeld(); _renderHeld();
_renderList(); _renderList();
@ -263,31 +191,6 @@ window.Page_lost = (() => {
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾'; : 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
} }
} catch { } 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.'); UI.toast.error('Meldungen konnten nicht geladen werden.');
} }
} }
@ -301,21 +204,20 @@ window.Page_lost = (() => {
_markers = []; _markers = [];
_reports.forEach(r => { _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({ const icon = L.divIcon({
className : '', className : '',
html : `<div style="background:${dotColor};color:#fff;border-radius:50%; html : `<div style="
width:34px;height:34px; background:#e74c3c;color:#fff;border-radius:50%;
display:flex;align-items:center;justify-content:center; width:34px;height:34px;
font-size:17px;border:2px solid #fff; display:flex;align-items:center;justify-content:center;
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`, font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">🐕</div>`,
iconSize : [34, 34], iconSize : [34, 34],
iconAnchor : [17, 17], iconAnchor : [17, 17],
}); });
const distStr = r.distanz_m !== undefined const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: ''; : '';
const marker = L.marker([r.lat, r.lon], { icon }) const marker = L.marker([r.lat, r.lon], { icon })
@ -324,11 +226,10 @@ window.Page_lost = (() => {
<b>🔍 ${_escape(r.name)}</b><br> <b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''} ${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''} ${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
<small>📅 ${_fmtDate(r.created_at)}</small> <small>📅 ${_fmtDate(r.created_at)}</small>
`); `);
if (!r._isPending) marker.on('click', () => _openDetail(r)); marker.on('click', () => _openDetail(r));
_markers.push(marker); _markers.push(marker);
}); });
} }
@ -370,19 +271,10 @@ window.Page_lost = (() => {
listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-lost-id]').forEach(card => { listEl.querySelectorAll('[data-lost-id]').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const id = card.dataset.lostId; const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
const r = _reports.find(x => String(x.id) === id && !x._isPending);
if (r) _openDetail(r); 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 => { listEl.querySelectorAll('.lost-note-btn').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -440,24 +332,14 @@ window.Page_lost = (() => {
Gemeldet ${_fmtDate(r.created_at)} Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div> </div>
${r._isPending ${_appState.user ? `<div style="margin-top:var(--space-2)">
? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap"> <button class="btn btn-ghost btn-xs lost-note-btn"
<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600"> Sync ausstehend</span> data-lost-note-id="${r.id}"
<button class="btn btn-ghost btn-xs lost-discard-btn" data-lost-note-name="${_escape(r.name)}"
data-pending-id="${r.id}" title="Notiz" onclick="event.stopPropagation()">
onclick="event.stopPropagation()" <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
style="color:var(--c-danger,#dc2626)"> </button>
🗑 Verwerfen </div>` : ''}
</button>
</div>`
: (_appState.user ? `<div style="margin-top:var(--space-2)">
<button class="btn btn-ghost btn-xs lost-note-btn"
data-lost-note-id="${r.id}"
data-lost-note-name="${_escape(r.name)}"
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
</div>` : '')}
</div> </div>
</div> </div>
</div> </div>
@ -468,7 +350,6 @@ window.Page_lost = (() => {
// DETAIL-MODAL // DETAIL-MODAL
// ---------------------------------------------------------- // ----------------------------------------------------------
function _openDetail(r) { 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 isOwn = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin'; const isAdmin = _appState.user?.rolle === 'admin';
const distStr = r.distanz_m !== undefined const distStr = r.distanz_m !== undefined
@ -751,23 +632,7 @@ window.Page_lost = (() => {
client_time : API.clientNow(), client_time : API.clientNow(),
}; };
let created; const created = await API.lost.report(payload);
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 // Foto hochladen
if (photoInput?.files[0]) { if (photoInput?.files[0]) {

View file

@ -11,11 +11,9 @@ window.Page_map = (() => {
let _map = null; let _map = null;
let _leafletLoaded = false; let _leafletLoaded = false;
let _userPos = null; let _userPos = null;
let _weatherLoaded = false; let _weatherLoaded = false;
let _placingMarker = false; let _placingMarker = false;
let _tempMarker = null; let _tempMarker = null;
let _tileLayer = null;
let _themeObserver = null;
// Standort-Tracking // Standort-Tracking
let _locationMarker = null; let _locationMarker = null;
@ -60,8 +58,7 @@ window.Page_map = (() => {
zuechter: [], 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 = {}; let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar // Gespeicherten Zustand laden, Fallback: alles sichtbar
@ -78,7 +75,7 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung // z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = { const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Café & Restaurant', color: '#F97316', z: 10 }, restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 }, freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 }, shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 }, kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
@ -95,7 +92,7 @@ window.Page_map = (() => {
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 }, community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 }, zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hotel', color: '#0369a1', z: 20 }, hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
}; };
// Frontend-Layer → Backend-Typ Mapping // Frontend-Layer → Backend-Typ Mapping
@ -183,7 +180,6 @@ window.Page_map = (() => {
<div class="map-legend" id="map-legend"> <div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden"> <button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
<span class="map-legend-label">Filter</span>
</button> </button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => ` ${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}"> <button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
@ -218,7 +214,7 @@ window.Page_map = (() => {
<div class="map-statusbar" id="map-statusbar"> <div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span> <span id="map-zoom-info"></span>
<span id="map-osm-status" style="display:none"></span> <span id="map-osm-status"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span> <span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span> <span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div> </div>
@ -530,16 +526,7 @@ window.Page_map = (() => {
if (!_userPos) { if (!_userPos) {
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200); _frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
} }
_tileLayer = _buildTileLayer(); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
_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(), 100);
setTimeout(() => _map.invalidateSize(), 600); setTimeout(() => _map.invalidateSize(), 600);
@ -613,7 +600,7 @@ window.Page_map = (() => {
width:36px;height:36px;border-radius:50%; width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4); box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(52,68,36,0.65)">${n}</div>`, border:2px solid rgba(255,255,255,0.8)">${n}</div>`,
iconSize: [36, 36], iconAnchor: [18, 18], iconSize: [36, 36], iconAnchor: [18, 18],
}); });
}, },
@ -625,34 +612,14 @@ window.Page_map = (() => {
return _clusterGroups[layerKey]; 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() { function _updateZoomDisplay() {
if (!_map) return; if (!_map) return;
const z = Math.round(_map.getZoom()); const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info'); const el = document.getElementById('map-zoom-info');
if (!el) return; if (!el) return;
if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; } if (z < 10) { el.textContent = `Zoom ${z} · ab 10: 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 if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; } else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
} }
function _setOsmStatus(text, pct = null) { function _setOsmStatus(text, pct = null) {
@ -970,7 +937,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%; width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35); box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`, border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
@ -1250,41 +1217,9 @@ window.Page_map = (() => {
API.breeder.mapMarkers(), API.breeder.mapMarkers(),
]); ]);
const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected'); if (places.status === 'fulfilled') _addPlaces(places.value);
if (allFailed) { if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
try { if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
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(); _scheduleOsmLoad();
} }
@ -1335,7 +1270,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%; width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35); box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`, border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
@ -1379,7 +1314,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%; width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35); box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`, border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 }) return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })

View file

@ -5,8 +5,6 @@
window.Page_poison = (() => { window.Page_poison = (() => {
const _CACHE_KEY = 'by_poison_cache';
// ---------------------------------------------------------- // ----------------------------------------------------------
// MODUL-STATE // MODUL-STATE
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -173,7 +171,6 @@ window.Page_poison = (() => {
try { try {
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000); _reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
_renderMarkers(); _renderMarkers();
_renderList(); _renderList();
_updateBadge(_reports.length); _updateBadge(_reports.length);
@ -183,18 +180,6 @@ window.Page_poison = (() => {
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).'; : 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
} }
} catch { } 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.'); UI.toast.error('Meldungen konnten nicht geladen werden.');
} }
} }
@ -543,12 +528,6 @@ window.Page_poison = (() => {
const created = await API.poison.report(payload); const created = await API.poison.report(payload);
// SW hat Request in Queue gelegt (offline)
if (created?._queued) {
_showPoisonThanks(true);
return;
}
// Foto hochladen // Foto hochladen
if (photoInput?.files[0]) { if (photoInput?.files[0]) {
try { try {
@ -561,7 +540,8 @@ window.Page_poison = (() => {
} }
} }
// Distanz client-seitig berechnen // Distanz client-seitig berechnen (für sofortige Anzeige)
// _userPos aktualisieren falls Picker neuen Standort geliefert hat
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon }; if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
created.distanz_m = _userPos created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
@ -573,45 +553,12 @@ window.Page_poison = (() => {
_updateBadge(_reports.length); _updateBadge(_reports.length);
App.checkNearbyAlerts(); App.checkNearbyAlerts();
App.callModule('map', 'refresh'); App.callModule('map', 'refresh');
_showPoisonThanks(false); UI.toast.success('Giftköder gemeldet! Danke für die Warnung.');
UI.modal.close();
}); });
}); });
} }
// ----------------------------------------------------------
// DANKE-OVERLAY nach Giftköder-Meldung
// ----------------------------------------------------------
function _showPoisonThanks(isQueued) {
const offlineNote = isQueued
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#wifi-slash"></use></svg>
Wird synchronisiert sobald du wieder online bist.
</p>`
: '';
UI.modal.open({
title: 'Danke für deine Meldung!',
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<div style="margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
</div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">
Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
</p>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);
margin:var(--space-2) 0 0;line-height:1.5;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Vielen Dank, dass du die Community schützt!
</p>
${offlineNote}
</div>
`,
footer: `<button class="btn btn-primary flex-1" id="poison-thanks-ok">OK</button>`,
});
document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close);
setTimeout(() => UI.modal.close(), 5000);
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// BADGE (Sidebar + Bottom-Nav) // BADGE (Sidebar + Bottom-Nav)
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -5,40 +5,6 @@
window.Page_routes = (() => { 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 _container = null;
let _appState = null; let _appState = null;
let _data = []; let _data = [];
@ -668,8 +634,7 @@ window.Page_routes = (() => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return; if (_recOvl) return;
try { await (UI.loadLeaflet?.() ?? Promise.resolve()); } await UI.loadLeaflet?.() ?? Promise.resolve();
catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); }
const ovl = document.createElement('div'); const ovl = document.createElement('div');
ovl.id = 'rk-rec-ovl'; ovl.id = 'rk-rec-ovl';
@ -726,37 +691,24 @@ window.Page_routes = (() => {
document.body.appendChild(ovl); document.body.appendChild(ovl);
_recOvl = ovl; _recOvl = ovl;
// Listener sofort nach DOM-Einfügen — nicht nach async-Operationen
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
// Map-Setup: Leaflet könnte offline fehlen → alles in try/catch
const pos = _userPos || { lat: 48.1, lon: 11.5 }; const pos = _userPos || { lat: 48.1, lon: 11.5 };
try { _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
if (!window.L) throw new Error('Leaflet not loaded'); .setView([pos.lat, pos.lon], 15);
_recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
.setView([pos.lat, pos.lon], 15); _recLocMarker = L.circleMarker([pos.lat, pos.lon], {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
_recLocMarker = L.circleMarker([pos.lat, pos.lon], { }).addTo(_recMap);
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 =
`<div style="display:flex;align-items:center;justify-content:center;height:100%;
flex-direction:column;gap:8px;color:var(--c-text-secondary);font-size:14px">
<span style="font-size:2rem">📡</span>
Karte offline nicht verfügbar GPS läuft trotzdem
</div>`;
}
// Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS) // Get accurate position
try { try {
const p = await API.getLocation(); const p = await API.getLocation();
_userPos = p; _userPos = p;
_recMap?.setView([p.lat, p.lon], 16); _recMap.setView([p.lat, p.lon], 16);
_recLocMarker?.setLatLng([p.lat, p.lon]); _recLocMarker.setLatLng([p.lat, p.lon]);
} catch {} } catch {}
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
} }
async function _startRecInOvl() { async function _startRecInOvl() {
@ -780,7 +732,6 @@ window.Page_routes = (() => {
setTimeout(() => banner.remove(), 9000); setTimeout(() => banner.remove(), 9000);
} }
const ctrl = document.getElementById('rk-rec-ctrl'); const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = ` ctrl.innerHTML = `
<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;position:relative;overflow:hidden;touch-action:none;user-select:none;"> <button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;position:relative;overflow:hidden;touch-action:none;user-select:none;">
@ -822,9 +773,7 @@ window.Page_routes = (() => {
btn.addEventListener('pointercancel', cancelHold); btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = ''; document.getElementById('rk-rec-stats-bar').style.display = '';
if (_recMap && window.L) { _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
}
await _recAcquireWakeLock(); await _recAcquireWakeLock();
document.addEventListener('visibilitychange', _recOnVisibility); document.addEventListener('visibilitychange', _recOnVisibility);
@ -839,9 +788,9 @@ window.Page_routes = (() => {
_recDistKm += d; _recDistKm += d;
} }
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) }); _recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
_recPolyline?.addLatLng([lat, lon]); _recPolyline.addLatLng([lat, lon]);
_recLocMarker?.setLatLng([lat, lon]); _recLocMarker.setLatLng([lat, lon]);
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16); if (_recTrack.length === 1) _recMap.setView([lat, lon], 16);
_updateRecStats(); _updateRecStats();
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 }); }, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
@ -1062,7 +1011,7 @@ window.Page_routes = (() => {
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]'); const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const payload = { const saved = await API.routes.create({
name: fd.name?.trim(), name: fd.name?.trim(),
beschreibung: fd.beschreibung || null, beschreibung: fd.beschreibung || null,
gps_track: track, gps_track: track,
@ -1075,15 +1024,7 @@ window.Page_routes = (() => {
is_public: 'is_public' in fd, is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(), 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.modal.close();
UI.toast.success(`Route „${saved.name}" gespeichert!`); UI.toast.success(`Route „${saved.name}" gespeichert!`);
_loadData(); _loadData();
@ -1268,36 +1209,20 @@ window.Page_routes = (() => {
// Daten // Daten
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _loadData() { 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 { try {
_data = await API.routes.list(); _data = await API.routes.list();
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {} // "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
_merge(true); if (_appState.user && _browseMode === 'mine') {
} catch { document.getElementById('rk-mine-group')?.style.setProperty('display', '');
try { }
const raw = localStorage.getItem(_CACHE_KEY); // Standort-abhängiger Filter im Entdecken-Modus
if (raw) { if (_browseMode === 'discover' && _userPos) {
_data = JSON.parse(raw).data || []; document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
UI.toast.info('Offline — zeige zuletzt geladene Routen.'); }
_merge(false); _applyFilter();
return; } catch (err) {
}
} catch {}
// Nur Pending-Routen zeigen wenn gar kein Cache
_data = _getPending();
if (_data.length) { _merge(false); return; }
document.getElementById('rk-grid').innerHTML = document.getElementById('rk-grid').innerHTML =
`<p style="color:var(--c-danger);padding:var(--space-6)">Offline — noch keine Routen gecacht.</p>`; `<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${UI.escape(err.message)}</p>`;
} }
} }
@ -1444,13 +1369,10 @@ window.Page_routes = (() => {
: ''; : '';
return ` return `
<div class="rk-card" data-id="${r.id}" ${r._isPending ? 'data-pending="1"' : ''}> <div class="rk-card" data-id="${r.id}">
<div class="rk-card-preview">${previewContent}</div> <div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body"> <div class="rk-card-body">
${authorLine} ${authorLine}
${r._isPending ? `<div style="font-size:10px;font-weight:700;color:var(--c-warning,#d97706);
margin-bottom:3px;display:flex;align-items:center;gap:4px">
${UI.icon('cloud-arrow-up')} Sync ausstehend</div>` : ''}
<div class="rk-card-name">${UI.escape(r.name)}</div> <div class="rk-card-name">${UI.escape(r.name)}</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0"> <div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0">
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}

View file

@ -307,36 +307,6 @@ window.Page_settings = (() => {
Wir schalten deinen Account manuell frei innerhalb von 24 Stunden. Wir schalten deinen Account manuell frei innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail. Wir melden uns mit den Zahlungsdetails per E-Mail.
</div> </div>
${!_appState.user?.billing_address ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)">
💡 Tipp: Trag deine <strong>Rechnungsadresse</strong> im Profil ein dann können wir die Rechnung vollständig ausstellen.
</div>` : ''}
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));">
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<input type="checkbox" id="agb-checkbox"
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
<span>
Ich habe die <span style="color:var(--c-primary);cursor:pointer"
onclick="App.navigate('agb')">AGB</span> gelesen und stimme ihnen zu.
</span>
</label>
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));">
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<input type="checkbox" id="widerruf-checkbox"
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
<span>
Ich stimme zu, dass mein Zugang sofort nach Freischaltung beginnt, und bestätige,
dass ich damit mein 14-tägiges Widerrufsrecht verliere (§&nbsp;356 Abs.&nbsp;4 BGB).
</span>
</label>
</div>
${breederForm} ${breederForm}
</div>`, </div>`,
footer: ` footer: `
@ -354,17 +324,6 @@ window.Page_settings = (() => {
</button>` </button>`
}); });
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 () => { document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('upgrade-request-send-btn'); const btn = document.getElementById('upgrade-request-send-btn');
if (!btn) return; if (!btn) return;
@ -398,8 +357,7 @@ window.Page_settings = (() => {
} }
try { try {
const widerrufAt = new Date().toLocaleString('de-DE'); const res = await API.auth.upgradeRequest(tier);
const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
UI.modal.close(); UI.modal.close();
if (res.already) { if (res.already) {
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.'); UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
@ -1177,22 +1135,6 @@ window.Page_settings = (() => {
value="${_esc(u.social_link || '')}" value="${_esc(u.social_link || '')}"
style="${inputStyle}"> style="${inputStyle}">
</div> </div>
<div style="border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-1)">
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:2px">Rechnungsadresse</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.</div>
<textarea name="billing_address" rows="2" maxlength="200"
placeholder="Musterstraße 1&#10;12345 Berlin"
style="${inputStyle};resize:vertical;font-family:inherit">${_esc(u.billing_address || '')}</textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Dein Geburtstag <span style="font-weight:400;color:var(--c-text-secondary)">(optional)</span></label>
<input name="geburtstag" type="text" maxlength="5" placeholder="TT.MM"
value="${_esc(u.geburtstag || '')}"
pattern="\\d{2}\\.\\d{2}"
title="Format: TT.MM, z.B. 16.05"
style="${inputStyle}">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">Wird nur für Geburtstagsgrüße in der App verwendet.</div>
</div>
<div> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label> <label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select> <select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
@ -1219,11 +1161,8 @@ window.Page_settings = (() => {
erfahrung: fd.erfahrung || '', erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '', social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public', profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
billing_address: fd.billing_address || '',
geburtstag: fd.geburtstag || '',
}); });
Object.assign(_appState.user, updated); Object.assign(_appState.user, updated);
window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
UI.modal.close?.(); UI.modal.close?.();
UI.toast.success('Profil gespeichert.'); UI.toast.success('Profil gespeichert.');
_render(); _render();

View file

@ -43,7 +43,6 @@ window.Page_social = (() => {
function _render() { function _render() {
const lvlBar = _stats ? _levelBar(_stats) : ''; const lvlBar = _stats ? _levelBar(_stats) : '';
_el.innerHTML = ` _el.innerHTML = `
<div style="width:100%;max-width:860px;margin:0 auto">
<div style="background:var(--c-surface);border-radius:var(--radius-lg); <div style="background:var(--c-surface);border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm);padding:var(--space-4); box-shadow:var(--shadow-sm);padding:var(--space-4);
margin-bottom:var(--space-4)"> margin-bottom:var(--space-4)">
@ -72,8 +71,7 @@ window.Page_social = (() => {
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}"> color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
${l}</button>`).join('')} ${l}</button>`).join('')}
</div> </div>
<div id="sm-content"></div> <div id="sm-content"></div>`;
</div>`;
_el.querySelectorAll('.sm-tab').forEach(b => _el.querySelectorAll('.sm-tab').forEach(b =>
b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); })); b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); }));

View file

@ -56,7 +56,6 @@ window.Page_uebungen = (() => {
{ id: 'welpe-basics', label: 'Welpe Basics' }, { id: 'welpe-basics', label: 'Welpe Basics' },
{ id: 'grundlagen', label: 'Trainingsgrundlagen' }, { id: 'grundlagen', label: 'Trainingsgrundlagen' },
{ id: 'ki-trainer', label: 'KI-Trainer' }, { id: 'ki-trainer', label: 'KI-Trainer' },
{ id: 'verlauf', label: 'Protokoll' },
]; ];
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -542,15 +541,11 @@ window.Page_uebungen = (() => {
_renderContent(); _renderContent();
} }
function onDogChange() { function onDogChange() {
_statsData = null; _statsData = null;
_badgesData = null; _badgesData = null;
_progressCache = {}; _progressCache = {};
_progressLoaded = false; _progressLoaded = false;
_exerciseStats = {}; _exerciseStats = {};
_verlaufSessions = [];
_verlaufOffset = 0;
_verlaufLoading = false;
_verlaufView = 'datum';
_render(); _render();
_loadStatsAndBadges(); _loadStatsAndBadges();
_loadVirtualTrainer(); _loadVirtualTrainer();
@ -563,21 +558,34 @@ window.Page_uebungen = (() => {
_container.innerHTML = ` _container.innerHTML = `
<div id="ueb-wrap"> <div id="ueb-wrap">
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div> <div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
<div style="padding:var(--space-2) var(--space-4) var(--space-2);display:flex;gap:var(--space-2);align-items:stretch"> <div style="padding:var(--space-3) var(--space-4) var(--space-2)">
<input type="search" id="ueb-search" placeholder="Übung suchen…" <table style="width:100%;border-collapse:collapse">
style="flex:1;min-width:0;padding:var(--space-2) var(--space-3); <tr>
border:1px solid var(--c-border);border-radius:var(--radius-md); <td style="width:100%;padding-right:var(--space-2)">
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm);outline:none"> <input type="search" id="ueb-search" placeholder="Übung suchen…"
<button id="ueb-quicksetup-btn" style="display:block;width:100%;box-sizing:border-box;
style="flex-shrink:0;width:64px; padding:var(--space-2) var(--space-3);
background:var(--c-primary-subtle);border:1.5px solid var(--c-primary-light); border:1px solid var(--c-border);border-radius:var(--radius-md);
border-radius:var(--radius-md);cursor:pointer; background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px"> outline:none" value="${_esc(_searchQuery)}">
<svg class="ph-icon" style="width:24px;height:24px;color:var(--c-primary)" aria-hidden="true"> </td>
<use href="/icons/phosphor.svg#paw-print"></use> <td style="white-space:nowrap;vertical-align:middle">
</svg> <button id="ueb-quicksetup-btn"
<span style="font-size:9px;font-weight:var(--weight-semibold);color:var(--c-primary);line-height:1.2;text-align:center">Stand<br>erfassen</span> style="padding:5px 12px;height:100%;
</button> background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-sm);cursor:pointer;
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:15px;height:15px;flex-shrink:0;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
</svg>
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--c-text);white-space:nowrap;line-height:1.2">Stand erfassen</span>
<span style="font-size:10px;color:var(--c-text-muted);white-space:nowrap;line-height:1.2">Wo stehst du bei jeder Übung?</span>
</span>
</button>
</td>
</tr>
</table>
</div> </div>
${_renderTabs()} ${_renderTabs()}
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div> <div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
@ -972,11 +980,10 @@ window.Page_uebungen = (() => {
const isExerciseTab = ['grundkommandos','tricks','problemverhalten', const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab); 'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
const isVerlauf = _activeTab === 'verlauf';
const showIf = v => v ? '' : 'none'; const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement; const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none'; if (quickWrap) quickWrap.style.display = showIf(isExerciseTab);
const trainerEl = _container.querySelector('#ueb-trainer'); const trainerEl = _container.querySelector('#ueb-trainer');
const suggestEl = _container.querySelector('#ueb-suggestions'); const suggestEl = _container.querySelector('#ueb-suggestions');
const bannerEl = _container.querySelector('#ueb-stats-banner'); const bannerEl = _container.querySelector('#ueb-stats-banner');
@ -1004,17 +1011,6 @@ window.Page_uebungen = (() => {
break; break;
} }
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
case 'verlauf': {
if (_verlaufSessions.length > 0) {
el.innerHTML = `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">${_verlaufToggleHtml()}<div id="verlauf-list"></div></div>`;
_renderVerlaufList(el.querySelector('#verlauf-list'));
} else {
el.innerHTML = _renderVerlaufShell();
_loadVerlauf();
}
_bindVerlaufToggle();
break;
}
case 'ki-trainer': case 'ki-trainer':
if (!App.hasPro(_appState?.user)) { if (!App.hasPro(_appState?.user)) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)"> el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
@ -1651,6 +1647,18 @@ window.Page_uebungen = (() => {
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea> background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
</div> </div>
<!-- Meilenstein-Checkbox (initially hidden) -->
<label id="ueb-log-milestone-wrap" hidden
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
padding:var(--space-3);background:var(--c-primary-subtle);
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
<input type="checkbox" id="ueb-log-milestone"
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
📖 Als Meilenstein ins Tagebuch eintragen
</span>
</label>
</form> </form>
<!-- Footer Buttons --> <!-- Footer Buttons -->
@ -1706,6 +1714,7 @@ window.Page_uebungen = (() => {
btn.style.background = 'var(--c-primary-subtle)'; btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)'; btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)'; btn.style.transform = 'scale(1.15)';
_checkMilestoneVisibility();
}); });
}); });
@ -1729,9 +1738,17 @@ window.Page_uebungen = (() => {
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => { overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35'; 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 // Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => { overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId(); const dogId = _dogId();
@ -1744,17 +1761,20 @@ window.Page_uebungen = (() => {
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`; const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10); 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 = { const body = {
dog_id: dogId, dog_id: dogId,
exercise_id: exerciseId, exercise_id: exerciseId,
exercise_name: exerciseName, exercise_name: exerciseName,
datum: today, datum: today,
wiederholungen: wiederholungen, wiederholungen: wiederholungen,
erfolgsquote: erfolgsquote, erfolgsquote: erfolgsquote,
hund_stimmung: stimmung || null, hund_stimmung: stimmung || null,
zufriedenheit: zufriedenheit || null, zufriedenheit: zufriedenheit || null,
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null, notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
tagebuch_eintrag: tagebuch,
}; };
try { try {
@ -1786,6 +1806,12 @@ window.Page_uebungen = (() => {
}); });
} }
if (resp.diary_entry_id) {
setTimeout(() => {
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner + Trainer aktualisieren // Stats-Banner + Trainer aktualisieren
_statsData = null; _statsData = null;
_loadStatsAndBadges(); _loadStatsAndBadges();
@ -1969,325 +1995,6 @@ window.Page_uebungen = (() => {
}); });
} }
// ----------------------------------------------------------
// TRAININGSPROTOKOLL (Verlauf-Tab)
// ----------------------------------------------------------
let _verlaufSessions = [];
let _verlaufOffset = 0;
let _verlaufHasMore = false;
let _verlaufLoading = false;
let _verlaufView = 'datum'; // 'datum' | 'uebung'
const _VERLAUF_LIMIT = 30;
const _ERFOLG_EMOJI = { 0: '😓', 25: '😐', 50: '🙂', 75: '😊', 100: '🎉' };
const _STIMMUNG_EMOJI = { aufmerksam: '🎯', müde: '😴', abgelenkt: '🌪️', super: '⚡' };
function _renderVerlaufShell() {
const dogId = _dogId();
if (!dogId) {
return `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<p style="font-size:var(--text-sm)">Wähle einen Hund aus um das Protokoll zu sehen.</p>
</div>`;
}
return `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
${_verlaufToggleHtml()}
<div id="verlauf-list" style="display:flex;flex-direction:column;gap:var(--space-2)">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>
</div>`;
}
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 `
<div style="display:flex;gap:var(--space-2)">
<button id="verlauf-btn-datum" style="${btnBase};${_verlaufView==='datum'?active:inactive}">
Nach Datum
</button>
<button id="verlauf-btn-uebung" style="${btnBase};${_verlaufView==='uebung'?active:inactive}">
Nach Übung
</button>
</div>`;
}
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 = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">Fehler beim Laden.</div>`;
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 = `
<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#clipboard-text"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Trainingseinheiten geloggt.</p>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Tippe in einer Übung auf "+ Einheit" um zu starten.
</p>
</div>`;
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
? `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:999px;
background:rgba(22,163,74,0.12);color:#15803d;border:1px solid rgba(22,163,74,0.3)">TOP</span>`
: '';
const noteHtml = s.notiz
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:3px;
line-height:1.4;font-style:italic">${_esc(s.notiz)}</div>`
: '';
return `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2)">
<span style="font-size:1.2rem;flex-shrink:0;margin-top:1px">${erfolg}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(s.exercise_name)}</span>
${topBadge}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:1px">
${s.wiederholungen}× Wdh.${stimmung ? ' · ' + stimmung : ''}${s.zufriedenheit ? ' · ' + '⭐'.repeat(s.zufriedenheit) : ''}
</div>
${noteHtml}
</div>
</div>`;
}
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 `
<div>
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:.05em;padding:var(--space-2) 0 var(--space-1)">
${_esc(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
${sessions.map(_sessionRow).join('')}
</div>
</div>`;
}).join('');
const moreBtn = _verlaufHasMore
? `<button id="verlauf-more"
style="width:100%;padding:var(--space-3);border:1px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary);cursor:pointer;
margin-top:var(--space-2)">
Weitere laden
</button>`
: '';
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 `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:4px var(--space-2);border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text-secondary)">
<span style="flex-shrink:0;min-width:52px">${_esc(dateLabel)}</span>
<span style="flex-shrink:0">${erfolg}</span>
<span style="flex-shrink:0">${s.erfolgsquote}%${top}</span>
<span style="flex:1;min-width:0">${s.wiederholungen}× Wdh.${stimmung ? ' ' + stimmung : ''}</span>
</div>`;
}).join('');
return `
<div class="card" style="padding:0;overflow:hidden">
<!-- Header (klickbar zum Aufklappen) -->
<button class="verlauf-ex-btn" data-uid="${uid}"
style="width:100%;padding:var(--space-3) var(--space-4);display:flex;
align-items:center;gap:var(--space-3);background:none;border:none;
cursor:pointer;text-align:left">
<!-- Fortschrittsring -->
<div style="flex-shrink:0;width:40px;height:40px;border-radius:50%;
background:${barBg};display:flex;flex-direction:column;
align-items:center;justify-content:center">
<span style="font-size:11px;font-weight:700;color:${barColor};line-height:1">${ex.avg}%</span>
<span style="font-size:9px;color:${barColor};line-height:1;margin-top:1px">
${TREND_ICON[ex.trend]}
</span>
</div>
<!-- Info -->
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);line-height:1.3;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(ex.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${ex.sessions.length} Einheit${ex.sessions.length !== 1 ? 'en' : ''}
${ex.topCount ? ' · ' + ex.topCount + '× TOP' : ''}
· ${_esc(lastLabel)}
</div>
</div>
<!-- Chevron -->
<svg class="ph-icon verlauf-ex-chevron" data-uid="${uid}"
style="width:16px;height:16px;flex-shrink:0;color:var(--c-text-muted);transition:transform .2s"
aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<!-- Eingeklappte Session-Liste -->
<div id="${uid}" hidden
style="border-top:1px solid var(--c-border);padding:var(--space-2) var(--space-3)">
${sessionRows}
</div>
</div>`;
}).join('');
const hint = _verlaufHasMore
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;padding:var(--space-2) 0">
Zeigt die letzten ${_verlaufSessions.length} Einheiten ältere nicht berücksichtigt.
</div>`
: '';
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 // TRAININGSGRUNDLAGEN
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -5,45 +5,6 @@
window.Page_walks = (() => { 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 _container = null;
let _appState = null; let _appState = null;
let _data = []; let _data = [];
@ -234,16 +195,14 @@ window.Page_walks = (() => {
// Daten laden // Daten laden
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _loadData() { async function _loadData() {
const pending = _getPending();
try { try {
const fetched = await API.walks.list( _data = await API.walks.list(
_userPos?.lat ?? null, _userPos?.lat ?? null,
_userPos?.lon ?? null _userPos?.lon ?? null
); );
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
_data = [...pending, ...fetched];
_renderList(); _renderList();
_renderMarkers(); _renderMarkers();
// Desktop: Karte direkt initialisieren (beide Panels sichtbar)
if (window.innerWidth >= 1024) { if (window.innerWidth >= 1024) {
UI.loadLeaflet().then(() => { UI.loadLeaflet().then(() => {
_initMap(); _initMap();
@ -251,20 +210,8 @@ window.Page_walks = (() => {
setTimeout(() => _map?.invalidateSize(), 400); setTimeout(() => _map?.invalidateSize(), 400);
}); });
} }
} catch { } catch (err) {
try { UI.toast.error(err.message || 'Fehler beim Laden.');
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.');
} }
} }
@ -344,7 +291,6 @@ window.Page_walks = (() => {
</span> </span>
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span> <span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''} ${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
${w._isPending ? `<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600">⏳ Sync ausstehend</span>` : ''}
</div> </div>
</div> </div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)"> <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
@ -1182,30 +1128,15 @@ window.Page_walks = (() => {
const idx = _data.findIndex(w => w.id === walk.id); const idx = _data.findIndex(w => w.id === walk.id);
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated }; if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
UI.toast.success('Treffen aktualisiert.'); UI.toast.success('Treffen aktualisiert.');
UI.modal.close();
_renderList();
_renderMarkers();
} else { } else {
let created; const created = await API.walks.create(payload);
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 }); _data.unshift({ ...created, teilnehmer_count: 0 });
UI.toast.success('Treffen geplant! 🎉'); UI.toast.success('Treffen geplant! 🎉');
UI.modal.close();
_renderList();
_renderMarkers();
} }
UI.modal.close();
_renderList();
_renderMarkers();
}); });
}); });
} }

View file

@ -167,8 +167,6 @@ window.Page_welcome = (() => {
<a href="/#impressum" style="color:var(--c-text-muted)">Impressum</a> <a href="/#impressum" style="color:var(--c-text-muted)">Impressum</a>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<a href="/#datenschutz" style="color:var(--c-text-muted)">Datenschutz</a> <a href="/#datenschutz" style="color:var(--c-text-muted)">Datenschutz</a>
&nbsp;·&nbsp;
<a href="/#agb" style="color:var(--c-text-muted)">AGB</a>
</p> </p>
</div> </div>
`; `;

View file

@ -115,20 +115,20 @@ window.Page_zuchthunde = (() => {
</svg> </svg>
</div>`; </div>`;
return ` return `
<div id="breeder-private-header" style="background:var(--c-bg-secondary); <div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid var(--c-border); border-bottom:1px solid rgba(196,132,58,.25);
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${logoHtml}
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2> text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256"> <svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </svg>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span> <span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
</div> </div>
</div> </div>
</div>`; </div>`;

View file

@ -83,19 +83,13 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay); document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open'); document.documentElement.classList.add('modal-open');
// Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
let _vvCleanup = null; let _vvCleanup = null;
const vv = window.visualViewport; const vv = window.visualViewport;
const modal = overlay.querySelector('.modal');
if (vv) { if (vv) {
const adjust = () => { const adjust = () => {
const visible = vv.height; const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
const offset = vv.offsetTop; overlay.style.paddingBottom = (kb + 16) + 'px';
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('resize', adjust);
vv.addEventListener('scroll', adjust); vv.addEventListener('scroll', adjust);
@ -103,37 +97,19 @@ const UI = (() => {
vv.removeEventListener('resize', adjust); vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust); vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = ''; overlay.style.paddingBottom = '';
if (modal) modal.style.maxHeight = '';
}; };
} }
// Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView _current = { overlay, onClose, _vvCleanup };
// 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'); return overlay.querySelector('.modal');
} }
function close() { function close() {
if (!_current) return; if (!_current) return;
const { onClose, _vvCleanup, _onFocusin } = _current; const { onClose, _vvCleanup } = _current;
onClose?.(); onClose?.();
_vvCleanup?.(); _vvCleanup?.();
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
_current.overlay.remove(); _current.overlay.remove();
document.documentElement.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open');
_current = null; _current = null;

View file

@ -598,21 +598,13 @@ window.Worlds = (() => {
let _cfgCache = null; let _cfgCache = null;
function _mergeDefaults(cfg) { function _mergeDefaults(cfg) {
const result = JSON.parse(JSON.stringify(cfg)); // Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen
const hidden = new Set(result.hidden || []); const result = JSON.parse(JSON.stringify(cfg));
// 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']) { for (const world of ['jetzt', 'hund', 'welt']) {
const def = _DEFAULT_CONFIG[world] || []; const def = _DEFAULT_CONFIG[world] || [];
const saved = result[world] || []; const saved = result[world] || [];
for (const page of def) { for (const page of def) {
// Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet if (!saved.includes(page)) saved.push(page);
if (!allAssigned.has(page) && !hidden.has(page)) {
saved.push(page);
allAssigned.add(page);
}
} }
result[world] = saved; result[world] = saved;
} }
@ -645,11 +637,6 @@ window.Worlds = (() => {
} }
function _saveConfig(cfg) { 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; _cfgCache = cfg;
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
if (_state?.user) { if (_state?.user) {
@ -723,18 +710,15 @@ window.Worlds = (() => {
const bottomNav = document.getElementById('bottom-nav'); const bottomNav = document.getElementById('bottom-nav');
if (bottomNav) bottomNav.style.display = 'none'; if (bottomNav) bottomNav.style.display = 'none';
const _isDesktop = window.innerWidth >= 768;
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.id = 'wc-overlay'; ov.id = 'wc-overlay';
ov.style.cssText = _isDesktop ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
? '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); document.body.appendChild(ov);
const _removeDragListeners = () => { const _removeDragListeners = () => {
document.removeEventListener('pointermove', _onDragMove); document.removeEventListener('touchmove', _onDragMove);
document.removeEventListener('pointerup', _onDragEnd); document.removeEventListener('touchend', _onDragEnd);
document.removeEventListener('pointercancel', _onDragEnd); document.removeEventListener('touchcancel', _onDragEnd);
}; };
const _cancelDrag = () => { const _cancelDrag = () => {
if (!_drag) return; if (!_drag) return;
@ -752,14 +736,11 @@ window.Worlds = (() => {
}; };
function _render() { 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 = ` ov.innerHTML = `
${!_isDesktop ? '<div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>' : ''} <div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>
<div id="wc-sheet" style="${_sheetStyle}"> <div id="wc-sheet" style="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)">
<!-- Header --> <!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between; <div style="display:flex;align-items:center;justify-content:space-between;
padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97); padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97);
@ -795,15 +776,15 @@ window.Worlds = (() => {
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''} ${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
</div> </div>
<div class="wc-zone" data-zone="${w}" <div class="wc-zone" data-zone="${w}"
style="display:grid;grid-template-columns:${_gridCols};grid-auto-rows:${_chipH};gap:${_isDesktop?'6px':'8px'}; style="display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:80px;gap:8px;
min-height:${w==='pool'&&chips.length===0?'40px':'auto'}; min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
border:2px dashed transparent;border-radius:16px;padding:4px; border:2px dashed transparent;border-radius:16px;padding:4px;
transition:border-color .2s"> transition:border-color .2s">
${chips.map(c => ` ${chips.map(c => `
<div class="wc-chip" data-page="${c.page}" data-zone="${w}" <div class="wc-chip" data-page="${c.page}" data-zone="${w}"
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col}; style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
border-radius:16px;padding:${_isDesktop?'6px 4px':'10px 4px 8px'};height:${_chipH};box-sizing:border-box; border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${_isDesktop?'3px':'5px'}; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
cursor:grab;position:relative;min-width:0;overflow:hidden; cursor:grab;position:relative;min-width:0;overflow:hidden;
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y"> user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
${!c.pinned ? ` ${!c.pinned ? `
@ -874,27 +855,29 @@ window.Worlds = (() => {
}); });
}); });
// Pointer-Drag (funktioniert auf Mouse + Touch) // Touch-Drag
ov.querySelectorAll('.wc-chip').forEach(chip => { ov.querySelectorAll('.wc-chip').forEach(chip => {
chip.addEventListener('pointerdown', e => _onDragStart(e, chip)); chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true });
}); });
document.addEventListener('touchmove', _onDragMove, { passive: false });
document.addEventListener('touchend', _onDragEnd);
} }
function _onDragStart(e, chipEl) { function _onDragStart(e, chipEl) {
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
if (_drag) _cancelDrag(); if (_drag) _cancelDrag();
chipEl.setPointerCapture(e.pointerId); const touch = e.touches[0];
// Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte)
_drag = { _drag = {
page: chipEl.dataset.page, zone: chipEl.dataset.zone, page: chipEl.dataset.page, zone: chipEl.dataset.zone,
chipEl, ghost: null, dropZone: null, active: false, chipEl, ghost: null, dropZone: null, active: false,
startX: e.clientX, startY: e.clientY, ox: 0, oy: 0, startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0,
}; };
document.addEventListener('pointermove', _onDragMove); document.addEventListener('touchmove', _onDragMove, { passive: false });
document.addEventListener('pointerup', _onDragEnd); document.addEventListener('touchend', _onDragEnd);
document.addEventListener('pointercancel', _onDragEnd); document.addEventListener('touchcancel', _onDragEnd);
} }
function _activateDrag(e) { function _activateDrag(touch) {
const rect = _drag.chipEl.getBoundingClientRect(); const rect = _drag.chipEl.getBoundingClientRect();
_drag.ox = _drag.startX - rect.left; _drag.ox = _drag.startX - rect.left;
_drag.oy = _drag.startY - rect.top; _drag.oy = _drag.startY - rect.top;
@ -908,8 +891,8 @@ window.Worlds = (() => {
ghost.style.transform = 'scale(1.08) rotate(-2deg)'; ghost.style.transform = 'scale(1.08) rotate(-2deg)';
ghost.style.width = rect.width + 'px'; ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px'; ghost.style.height = rect.height + 'px';
ghost.style.left = (e.clientX - _drag.ox) + 'px'; ghost.style.left = (touch.clientX - _drag.ox) + 'px';
ghost.style.top = (e.clientY - _drag.oy) + 'px'; ghost.style.top = (touch.clientY - _drag.oy) + 'px';
ghost.style.transition = 'none'; ghost.style.transition = 'none';
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
document.body.appendChild(ghost); document.body.appendChild(ghost);
@ -919,22 +902,24 @@ window.Worlds = (() => {
function _onDragMove(e) { function _onDragMove(e) {
if (!_drag) return; if (!_drag) return;
const touch = e.touches[0];
if (!_drag.active) { if (!_drag.active) {
const dx = Math.abs(e.clientX - _drag.startX); const dx = Math.abs(touch.clientX - _drag.startX);
const dy = Math.abs(e.clientY - _drag.startY); const dy = Math.abs(touch.clientY - _drag.startY);
if (dx < 8 && dy < 8) return; if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht
_activateDrag(e); _activateDrag(touch);
} }
_drag.ghost.style.left = (e.clientX - _drag.ox) + 'px'; e.preventDefault(); // Scroll erst NACH Threshold blockieren
_drag.ghost.style.top = (e.clientY - _drag.oy) + 'px'; _drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px';
_drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px';
let foundZone = null; let foundZone = null;
ov.querySelectorAll('.wc-zone').forEach(z => { ov.querySelectorAll('.wc-zone').forEach(z => {
const r = z.getBoundingClientRect(); const r = z.getBoundingClientRect();
const over = e.clientX >= r.left && e.clientX <= r.right const over = touch.clientX >= r.left && touch.clientX <= r.right
&& e.clientY >= r.top && e.clientY <= r.bottom; && touch.clientY >= r.top && touch.clientY <= r.bottom;
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent'; z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
if (over) foundZone = z.dataset.zone; if (over) foundZone = z.dataset.zone;
}); });
@ -982,19 +967,6 @@ window.Worlds = (() => {
let _bgUrl = null; // aktuell gesetztes Hintergrundbild 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() { function _applyBgOrientation() {
const ov = document.getElementById('worlds-overlay'); const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track'); const track = document.getElementById('worlds-track');
@ -1003,14 +975,14 @@ window.Worlds = (() => {
if (portrait) { if (portrait) {
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe // Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
ov.style.backgroundImage = ''; ov.style.backgroundImage = '';
track.style.backgroundImage = _bgWithOverlay(_bgUrl); track.style.backgroundImage = `url('${_bgUrl}')`;
track.style.backgroundSize = 'cover'; track.style.backgroundSize = 'cover';
track.style.backgroundPosition = 'center 40%'; track.style.backgroundPosition = 'center 40%';
track.style.backgroundRepeat = 'no-repeat'; track.style.backgroundRepeat = 'no-repeat';
} else { } else {
// Vollbild pro Welt (Landscape / Desktop) // Vollbild pro Welt (Landscape / Desktop)
track.style.backgroundImage = ''; track.style.backgroundImage = '';
ov.style.backgroundImage = _bgWithOverlay(_bgUrl); ov.style.backgroundImage = `url('${_bgUrl}')`;
ov.style.backgroundSize = 'cover'; ov.style.backgroundSize = 'cover';
ov.style.backgroundPosition = 'center 40%'; ov.style.backgroundPosition = 'center 40%';
ov.style.backgroundRepeat = 'no-repeat'; ov.style.backgroundRepeat = 'no-repeat';
@ -1020,10 +992,6 @@ window.Worlds = (() => {
// Orientierungswechsel → Bild neu setzen // Orientierungswechsel → Bild neu setzen
window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation); 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) { function _applyBgImage(url) {
const ov = document.getElementById('worlds-overlay'); const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track'); const track = document.getElementById('worlds-track');
@ -1034,20 +1002,7 @@ window.Worlds = (() => {
_hasBgPhoto = true; _hasBgPhoto = true;
_bgUrl = url; _bgUrl = url;
_applyBgOrientation(); _applyBgOrientation();
const hint = document.getElementById('wh-photo-hint'); document.getElementById('wh-photo-hint')?.remove();
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.onerror = () => _applyBgImage(null);
toLoad.src = url; toLoad.src = url;
@ -1122,15 +1077,9 @@ window.Worlds = (() => {
} else if (!dog) { _applyBgImage(null); } } else if (!dog) { _applyBgImage(null); }
const hour = new Date().getHours(); 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 firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); 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 const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : ''; ? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
@ -1155,26 +1104,6 @@ window.Worlds = (() => {
: (w.temp_c ?? 20) < 2 ? '🌨️' : (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️'; : '☀️';
// User-Geburtstag Reminder
const userBdayHtml = userBdayToday ? `
<div class="world-reminder" style="border-color:rgba(196,132,58,0.6);
flex-direction:column;align-items:center;text-align:center;gap:6px;padding:12px 14px">
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
<svg class="ph-icon bday-fw1" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
<svg class="ph-icon bday-pop" style="width:1.8rem;height:1.8rem;color:#fff">
<use href="/icons/phosphor.svg#cake"></use></svg>
<svg class="ph-icon bday-fw2" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
</div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff">
Alles Gute zum Geburtstag, ${_esc(firstName)}!
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.55)">
Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
</div>
</div>` : '';
// Alert-Reminder // Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => ` const alertHtml = alertList.slice(0,1).map(a => `
<div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)"> <div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)">
@ -1221,7 +1150,6 @@ window.Worlds = (() => {
${user ? userAvatarHtml : ''} ${user ? userAvatarHtml : ''}
</div> </div>
</div> </div>
${userBdayHtml}
${alertHtml} ${alertHtml}
${user && dog ? ` ${user && dog ? `
<div class="wj-chip-row"> <div class="wj-chip-row">
@ -1258,6 +1186,7 @@ window.Worlds = (() => {
</div>` : ''} </div>` : ''}
</div> </div>
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Deine Bereiche</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')} ${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
</div> </div>
@ -1280,7 +1209,8 @@ window.Worlds = (() => {
try { try {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`); const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise; const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || 'Stand erfassen →'; valEl.textContent = ex?.name || '—';
// Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
const chip = document.getElementById('wj-exercise-chip'); const chip = document.getElementById('wj-exercise-chip');
if (chip) { if (chip) {
chip.style.cursor = 'pointer'; chip.style.cursor = 'pointer';
@ -1291,7 +1221,7 @@ window.Worlds = (() => {
); );
}; };
} }
} catch { valEl.textContent = 'Stand erfassen →'; } } catch { valEl.textContent = ''; }
} }
async function _loadJetztRoute() { async function _loadJetztRoute() {
@ -1412,13 +1342,8 @@ window.Worlds = (() => {
if (mmdd === `${mt}-${dt}`) return 'tomorrow'; if (mmdd === `${mt}-${dt}`) return 'tomorrow';
return null; return null;
} }
const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null; const bday = _birthdayState(dog.geburtstag);
// Großes Banner nur wenn der AKTIVE Hund Geburtstag hat const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
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([ const [streakRes, diaryRes] = await Promise.allSettled([
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
@ -1477,22 +1402,6 @@ window.Worlds = (() => {
</div> </div>
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div> <div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
</div> </div>
${otherBdayDog ? `
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.12);
display:flex;align-items:center;gap:8px;cursor:pointer"
id="wh-other-bday-hint">
<span style="animation:by-bday-bounce 1.2s ease-in-out infinite;display:inline-flex">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#f59e0b" aria-hidden="true">
<use href="/icons/phosphor.svg#cake"></use>
</svg>
</span>
<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.75);font-weight:600">
${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
</span>
<svg class="ph-icon" style="width:.9rem;height:.9rem;color:rgba(196,132,58,0.8);margin-left:auto" aria-hidden="true">
<use href="/icons/phosphor.svg#arrow-circle-right"></use>
</svg>
</div>` : ''}
</div> </div>
${bday ? ` ${bday ? `
<style> <style>
@ -1519,8 +1428,8 @@ window.Worlds = (() => {
</div> </div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em"> <div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
${bday === 'today' ${bday === 'today'
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!` ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!`
: `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`} : `Morgen hat ${_esc(dog.name)} Geburtstag!`}
</div> </div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg> <svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
@ -1534,7 +1443,7 @@ window.Worlds = (() => {
</div>` : ''} </div>` : ''}
<div style="display:flex;align-items:center;gap:4px;font-size:10px;color:rgba(196,132,58,0.9);font-weight:700;margin-top:2px"> <div style="display:flex;align-items:center;gap:4px;font-size:10px;color:rgba(196,132,58,0.9);font-weight:700;margin-top:2px">
<svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg> <svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg>
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'} ${bday === 'today' ? 'Was hat sich Ban Yaro gewünscht?' : 'KI-Überraschungsideen'}
</div> </div>
</div> </div>
${bday === 'today' && new Date().getHours() >= 18 ? ` ${bday === 'today' && new Date().getHours() >= 18 ? `
@ -1563,18 +1472,17 @@ window.Worlds = (() => {
Hintergrund-Foto hinzufügen Hintergrund-Foto hinzufügen
</div> </div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px"> <div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos im Querformat erscheinen hier als Panorama Tagebuchfotos erscheinen hier als Panorama
</div> </div>
</div> </div>
</div> </div>
` : ''} ` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span> <span data-wnav="gruender">Die 100 Gründer</span>
<span>·</span>
<span data-wnav="partner">Unsere Partner</span>
</div> </div>
</div> </div>
`; `;
@ -1601,19 +1509,6 @@ window.Worlds = (() => {
if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); } if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); }
}); });
}); });
// Geburtstag-Hinweis → zum Geburtstagshund wechseln
if (otherBdayDog) {
if (!document.getElementById('by-bday-anim-style')) {
const s = document.createElement('style');
s.id = 'by-bday-anim-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.3)}}';
document.head.appendChild(s);
}
el.querySelector('#wh-other-bday-hint')?.addEventListener('click', () => {
const idx = _dogs.indexOf(otherBdayDog);
if (idx >= 0) { _dogIdx = idx; _renderHund(); }
});
}
// Geburtstags-Banner → KI // Geburtstags-Banner → KI
el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday)); el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday));
@ -1668,10 +1563,10 @@ window.Worlds = (() => {
try { try {
const res = await API.post('/ki/geburtstag', { const res = await API.post('/ki/geburtstag', {
dog_id: bdayDog.id, dog_id: dog.id,
name: bdayDog.name, name: dog.name,
rasse: bdayDog.rasse || null, rasse: dog.rasse || null,
alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null, alter: dog.alter_jahre ? Math.round(dog.alter_jahre) : null,
mode: bdayMode, mode: bdayMode,
}); });
const body = ov.querySelector('#bday-ki-body'); const body = ov.querySelector('#bday-ki-body');
@ -1756,13 +1651,12 @@ window.Worlds = (() => {
</div> </div>
</div> </div>
<div class="world-bottom"> <div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid"> <div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')} ${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div> </div>
<div class="world-footer-links"> <div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span> <span data-wnav="datenschutz">Datenschutz</span>
<span style="color:var(--c-border)">·</span>
<span data-wnav="agb">AGB</span>
</div> </div>
</div> </div>
`; `;
@ -1794,7 +1688,7 @@ window.Worlds = (() => {
const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 }); const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 });
const [p, l] = await Promise.allSettled([ const [p, l] = await Promise.allSettled([
API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []), API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
]); ]);
if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' }); if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' });
if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' }); if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' });
@ -1808,26 +1702,6 @@ window.Worlds = (() => {
`).join(''); `).join('');
} }
function _updateBdayTabIndicator(bdayDog) {
if (bdayDog && !document.getElementById('by-bday-tab-style')) {
const s = document.createElement('style');
s.id = 'by-bday-tab-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.25)}}' +
'.wlabel-bday-ic{display:inline-block;animation:by-bday-bounce 1.2s ease-in-out infinite;margin-left:3px;font-size:.85em}';
document.head.appendChild(s);
}
const hundTab = document.querySelectorAll('#world-labels .wlabel')[1];
if (!hundTab) return;
hundTab.querySelector('.wlabel-bday-ic')?.remove();
if (bdayDog) {
const ic = document.createElement('span');
ic.className = 'wlabel-bday-ic';
ic.textContent = '🎂';
ic.title = `${bdayDog.name} hat ${_birthdayState(bdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!`;
hundTab.appendChild(ic);
}
}
function _fmtDate(d) { function _fmtDate(d) {
if (!d) return ''; if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); } try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); }

View file

@ -741,7 +741,13 @@
<span class="badge">Made in Germany</span> <span class="badge">Made in Germany</span>
<span class="badge">Offline-fähig</span> <span class="badge">Offline-fähig</span>
</div> </div>
<div class="hero-stats" id="hero-stats" style="display:none"></div> <div class="hero-stats" id="hero-stats" style="display:none">
<strong id="stat-users"></strong> Hundemenschen
<span class="sep">·</span>
<strong id="stat-dogs"></strong> Hunde
<span class="sep">·</span>
<strong id="stat-km"></strong> km Gassi-Wege
</div>
</div> </div>
</header> </header>
@ -818,14 +824,6 @@
<div class="stats-band-num" id="big-posts"></div> <div class="stats-band-num" id="big-posts"></div>
<div class="stats-band-label">Forum-Beiträge</div> <div class="stats-band-label">Forum-Beiträge</div>
</div> </div>
<div class="stats-band-item fade-up">
<div class="stats-band-num" id="big-diary"></div>
<div class="stats-band-label">Tagebuch-Einträge</div>
</div>
<div class="stats-band-item fade-up">
<div class="stats-band-num" id="big-kotbeutel"></div>
<div class="stats-band-label">Mülleimer für Kotbeutel</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -1115,77 +1113,6 @@
</div> </div>
</div> </div>
<!-- Läufigkeit Feature Spotlight -->
<div style="margin-top:3rem;display:grid;grid-template-columns:1fr 1fr;gap:2.5rem;align-items:center">
<div>
<p style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:#C4843A;margin:0 0 .5rem">Läufigkeit &amp; Trächtigkeit</p>
<h3 style="margin:0 0 1rem;font-size:1.4rem">Kein Zettelkaos mehr.</h3>
<p style="color:#555;line-height:1.7;margin:0 0 1.25rem">Progesterontests, Deckdaten und Trächtigkeits-Meilensteine an einem Ort. Die App berechnet automatisch wann der früheste Ultraschall möglich ist, wann der Bauch sichtbar wird — und erinnert dich rechtzeitig.</p>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem;color:#444;font-size:.9rem">
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Zykluskalender mit Beginn, Ende und Dauer
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Progesteronkurve: Werte, Labor, Übersicht
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
8 automatische Trächtigkeits-Meilensteine
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Deckdaten: Rüde, Deckart, Ultraschall-Ergebnis
</li>
</ul>
</div>
<div style="background:linear-gradient(135deg,#1a1208,#2d1f0e);border-radius:16px;padding:1.25rem;font-size:.82rem;color:white">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:rgba(196,132,58,.7);margin-bottom:.75rem">Beispiel — Luna vom Bergwald</div>
<!-- Deckung -->
<div style="background:rgba(255,255,255,.06);border:1px solid rgba(196,132,58,.3);border-radius:10px;padding:.75rem;margin-bottom:.5rem">
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem">
<svg viewBox="0 0 256 256" style="width:.9rem;height:.9rem;fill:#e74c7a;flex-shrink:0"><use href="/icons/phosphor.svg#heart"></use></svg>
<span style="font-weight:700">Deckung 14.05.2026</span>
<span style="background:rgba(34,197,94,.2);color:#4ade80;border-radius:4px;padding:1px 6px;font-size:.7rem;font-weight:600;margin-left:auto">Trächtig ✓</span>
</div>
<div style="color:rgba(255,255,255,.55);font-size:.75rem">Blitz vom Schwarzwaldforst ZB 44201 · Natürlich</div>
<!-- Nächster Meilenstein -->
<div style="background:rgba(196,132,58,.15);border:1px solid rgba(196,132,58,.4);border-radius:6px;padding:.4rem .6rem;margin-top:.5rem;font-size:.72rem;display:flex;align-items:center;gap:.35rem">
<svg viewBox="0 0 256 256" style="width:.75rem;height:.75rem;fill:#C4843A;flex-shrink:0"><use href="/icons/phosphor.svg#calendar-check"></use></svg>
<span style="color:#f5c07a;font-weight:600">Frühester Ultraschall · Tag 21 · 04.06.2026</span>
</div>
</div>
<!-- Meilensteine -->
<div style="display:flex;flex-direction:column;gap:.3rem">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.35);margin-bottom:.1rem">Trächtigkeits-Meilensteine</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">21</span>04.06. Frühester Ultraschall möglich</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">25</span>08.06. Welpen erkennbar im Ultraschall</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">35</span>18.06. Bauch wird sichtbar</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.5);font-size:.72rem;margin-top:.1rem">+ 5 weitere Meilensteine bis Geburtstermin</div>
</div>
<!-- Progesterontests -->
<div style="border-top:1px solid rgba(255,255,255,.1);margin-top:.75rem;padding-top:.75rem">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.35);margin-bottom:.4rem">Progesteronkurve</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;height:40px">
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#C4843A;width:100%;border-radius:2px 2px 0 0;height:30%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">10.8</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#C4843A;width:100%;border-radius:2px 2px 0 0;height:65%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">17.5</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#f5c07a;width:100%;border-radius:2px 2px 0 0;height:100%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">22.3</span>
</div>
</div>
<div style="font-size:.65rem;color:rgba(255,255,255,.3);margin-top:.25rem">ng/ml · Ovulation erreicht</div>
</div>
</div>
</div>
</div> </div>
</section> </section>
@ -1600,7 +1527,6 @@
<div class="footer-links"> <div class="footer-links">
<a href="/#impressum">Impressum</a> <a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a> <a href="/#datenschutz">Datenschutz</a>
<a href="/#agb">AGB</a>
<a href="/presse">Presse</a> <a href="/presse">Presse</a>
</div> </div>
</div> </div>
@ -1637,36 +1563,26 @@
fetch('/api/stats/public') fetch('/api/stats/public')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
var ids = {
users: ['stat-users', 'big-users'],
dogs: ['stat-dogs', 'big-dogs'],
km: ['stat-km', 'big-km'],
forum_posts: [null, 'big-posts'],
};
function set(id, val) { function set(id, val) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val); if (el) el.textContent = fmt.format(val);
} }
// Stats-Band (weiter unten) set('stat-users', d.users);
set('big-users', d.users); set('stat-dogs', d.dogs);
set('big-dogs', d.dogs); set('stat-km', d.km);
set('big-km', d.km); set('big-users', d.users);
set('big-posts', d.forum_posts); set('big-dogs', d.dogs);
set('big-diary', d.diary_entries); set('big-km', d.km);
set('big-kotbeutel', d.kotbeutel); set('big-posts', d.forum_posts);
// Hero-Streifen: aufsteigend nach Wert sortiert, dynamisch aufgebaut
var heroStats = document.getElementById('hero-stats'); var heroStats = document.getElementById('hero-stats');
if (!heroStats || !d.users) return; if (heroStats && d.users > 0) heroStats.style.display = 'flex';
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
}) })
.catch(function() {}); .catch(function() {});
</script> </script>

View file

@ -3,35 +3,20 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab const CACHE_VERSION = 'by-v961';
const VER = '1070';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend)
const PRIORITY_PAGES = [
'/js/pages/admin.js',
'/js/pages/erste-hilfe.js',
'/js/pages/diary.js',
'/js/pages/map.js',
'/js/pages/walks.js',
'/js/pages/routes.js',
'/js/pages/poison.js',
'/js/pages/lost.js',
];
// index.html wird NICHT pre-gecacht (immer Network-First) // index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [ const STATIC_ASSETS = [
`/css/design-system.css?v=${VER}`, '/css/design-system.css?v=700',
`/css/layout.css?v=${VER}`, '/css/layout.css?v=700',
`/css/components.css?v=${VER}`, '/css/components.css?v=700',
'/icons/phosphor.svg', '/icons/phosphor.svg',
`/js/api.js?v=${VER}`, '/js/api.js',
`/js/ui.js?v=${VER}`, '/js/ui.js',
`/js/app.js?v=${VER}`, '/js/app.js',
`/js/worlds.js?v=${VER}`,
'/js/leaflet.markercluster.js', '/js/leaflet.markercluster.js',
'/css/MarkerCluster.css', '/css/MarkerCluster.css',
'/css/MarkerCluster.Default.css', '/css/MarkerCluster.Default.css',
@ -122,8 +107,6 @@ const _QUEUEABLE = [
{ re: /^\/api\/training\/sessions$/, methods: ['POST'] }, { re: /^\/api\/training\/sessions$/, methods: ['POST'] },
{ re: /^\/api\/training\/progress$/, methods: ['POST'] }, { re: /^\/api\/training\/progress$/, methods: ['POST'] },
{ re: /^\/api\/poison$/, methods: ['POST'] }, { re: /^\/api\/poison$/, methods: ['POST'] },
{ re: /^\/api\/lost\/report$/, methods: ['POST'] },
{ re: /^\/api\/walks$/, methods: ['POST'] },
]; ];
function _isQueueable(pathname, method) { function _isQueueable(pathname, method) {
return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname)); return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname));
@ -143,14 +126,6 @@ const _CACHEABLE_GET = [
/^\/api\/training\/plan-progress/, /^\/api\/training\/plan-progress/,
/^\/api\/wiki\/rassen/, /^\/api\/wiki\/rassen/,
/^\/api\/dogs\/\d+\/diary\/stats/, /^\/api\/dogs\/\d+\/diary\/stats/,
/^\/api\/routes/,
/^\/api\/places/,
/^\/api\/breeder\/map-markers/,
/^\/api\/gassi-zeiten/,
/^\/api\/poison/,
/^\/api\/walks/,
/^\/api\/lost/,
/^\/api\/expenses/,
// Drei Welten — offline-fähig // Drei Welten — offline-fähig
/^\/api\/streak\/\d+/, /^\/api\/streak\/\d+/,
/^\/api\/forum\/threads/, /^\/api\/forum\/threads/,
@ -187,22 +162,13 @@ function _cacheMark(pathname) {
// INSTALL — App Shell cachen // INSTALL — App Shell cachen
// ---------------------------------------------------------- // ----------------------------------------------------------
self.addEventListener('install', event => { self.addEventListener('install', event => {
self.skipWaiting(); self.skipWaiting(); // Sofort übernehmen — kein Warten auf Cache-Aufbau
event.waitUntil( event.waitUntil(
caches.open(CACHE_STATIC) caches.open(CACHE_STATIC)
.then(cache => cache.addAll(STATIC_ASSETS)) .then(cache => cache.addAll(STATIC_ASSETS))
.then(() => { .then(() => caches.open(CACHE_API).then(c =>
// Prioritäts-Seiten nicht-blockierend im Hintergrund cachen fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {})
caches.open(CACHE_STATIC).then(cache => { ))
PRIORITY_PAGES.forEach(page =>
fetch(page).then(r => { if (r.ok) cache.put(page, r.clone()); }).catch(() => {})
);
});
// Training-Exercises vorwärmen
return caches.open(CACHE_API).then(c =>
fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {})
);
})
); );
}); });
@ -331,7 +297,7 @@ self.addEventListener('fetch', event => {
return; return;
} }
// CSS, Core-JS + Seiten-Module: Network-First mit ignoreSearch-Fallback für Offline // CSS, Core-JS + Seiten-Module: immer Network-First — damit iOS nie veraltete Versionen cached
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/') if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')
|| url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js') || url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js')
|| url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) { || url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) {
@ -344,7 +310,7 @@ self.addEventListener('fetch', event => {
} }
return response; return response;
}) })
.catch(() => caches.match(event.request, { ignoreSearch: true }) .catch(() => caches.match(event.request)
.then(cached => cached || new Response('', { status: 503 }))) .then(cached => cached || new Response('', { status: 503 })))
); );
return; return;

View file

@ -592,7 +592,6 @@
<div class="footer-links"> <div class="footer-links">
<a href="/#impressum">Impressum</a> <a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a> <a href="/#datenschutz">Datenschutz</a>
<a href="/#agb">AGB</a>
<a href="/info">Über Ban Yaro</a> <a href="/info">Über Ban Yaro</a>
<a href="/presse">Presse</a> <a href="/presse">Presse</a>
</div> </div>

View file

@ -239,9 +239,7 @@ def _wind_dir(deg: float) -> str:
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]: def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
# UV-Bonus skaliert mit Temperatur: unter 10°C kaum Aufheizung, ab 30°C voll bonus = min(uv_max * 3.0, 30.0)
t_factor = max(0.0, min(1.0, (air_max - 5) / 25))
bonus = min(uv_max * 3.0 * t_factor, 30.0)
asphalt = air_max + bonus asphalt = air_max + bonus
if asphalt <= 30: if asphalt <= 30:
warn = 'safe' warn = 'safe'

View file

@ -8,7 +8,6 @@ services:
volumes: volumes:
- ./data:/data - ./data:/data
- /volume1/docker/banyaro/data/media:/prod-media:ro - /volume1/docker/banyaro/data/media:/prod-media:ro
- /volume1/scaninput:/scaninput
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -7,7 +7,6 @@ services:
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
volumes: volumes:
- ./data:/data # SQLite + Media persistent - ./data:/data # SQLite + Media persistent
- /volume1/scaninput:/scaninput
env_file: env_file:
- .env - .env
environment: environment: