Merge branch 'develop'
This commit is contained in:
commit
f42f181e44
22 changed files with 960 additions and 128 deletions
|
|
@ -2341,6 +2341,24 @@ def _migrate(conn_factory):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# upgrade_requests: Abo-Upgrade-Anfragen von Nutzern
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS upgrade_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
fulfilled_at TEXT,
|
||||||
|
fulfilled_by INTEGER REFERENCES users(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrade_req_pending ON upgrade_requests(fulfilled_at, created_at DESC)")
|
||||||
|
logger.info("Migration: upgrade_requests bereit.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration upgrade_requests: {e}")
|
||||||
|
|
||||||
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
|
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
|
||||||
try:
|
try:
|
||||||
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
|
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
|
||||||
|
|
|
||||||
|
|
@ -406,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 = "918" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "939" # 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():
|
||||||
|
|
@ -465,10 +465,11 @@ async def sitemap():
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
urls = [
|
urls = [
|
||||||
("https://banyaro.app/", "weekly", "1.0"),
|
("https://banyaro.app/", "weekly", "1.0"),
|
||||||
("https://banyaro.app/info", "monthly", "0.9"),
|
("https://banyaro.app/zuechter", "weekly", "0.9"),
|
||||||
("https://banyaro.app/presse", "monthly", "0.8"),
|
("https://banyaro.app/info", "monthly", "0.8"),
|
||||||
|
("https://banyaro.app/presse", "monthly", "0.7"),
|
||||||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||||||
("https://banyaro.app/knigge", "monthly", "0.8"),
|
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||||||
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,12 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
|
||||||
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
|
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
|
||||||
Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz).
|
Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz).
|
||||||
"""
|
"""
|
||||||
relative = url.lstrip("/media/").lstrip("/")
|
if url.startswith("/media/"):
|
||||||
|
relative = url[len("/media/"):]
|
||||||
|
elif url.startswith("/"):
|
||||||
|
relative = url[1:]
|
||||||
|
else:
|
||||||
|
relative = url
|
||||||
candidate = os.path.realpath(os.path.join(media_dir, relative))
|
candidate = os.path.realpath(os.path.join(media_dir, relative))
|
||||||
real_base = os.path.realpath(media_dir)
|
real_base = os.path.realpath(media_dir)
|
||||||
if not candidate.startswith(real_base + os.sep) and candidate != real_base:
|
if not candidate.startswith(real_base + os.sep) and candidate != real_base:
|
||||||
|
|
|
||||||
|
|
@ -124,13 +124,20 @@ async def action_items(user=Depends(require_mod)):
|
||||||
users_today = conn.execute(
|
users_today = conn.execute(
|
||||||
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
|
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
try:
|
||||||
|
upgrades_pending = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
upgrades_pending = 0
|
||||||
return {
|
return {
|
||||||
"jobs_pending": jobs,
|
"jobs_pending": jobs,
|
||||||
"breeder_pending": breeders,
|
"breeder_pending": breeders,
|
||||||
"reports_open": reports,
|
"reports_open": reports,
|
||||||
"fotos_pending": fotos,
|
"fotos_pending": fotos,
|
||||||
"poi_edits_pending": poi_edits,
|
"poi_edits_pending": poi_edits,
|
||||||
"users_today": users_today,
|
"users_today": users_today,
|
||||||
|
"upgrades_pending": upgrades_pending,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1054,21 +1061,25 @@ async def ors_stats(user=Depends(require_mod)):
|
||||||
|
|
||||||
@router.post("/media/generate-previews")
|
@router.post("/media/generate-previews")
|
||||||
async def generate_media_previews(user=Depends(require_admin)):
|
async def generate_media_previews(user=Depends(require_admin)):
|
||||||
"""Generiert fehlende _preview.jpg für alle Bilder in /data/media."""
|
"""Generiert fehlende _preview.webp für alle Bilder in /data/media."""
|
||||||
import io as _io
|
import logging as _log
|
||||||
from media_utils import generate_preview, _PREVIEW_EXTS
|
from media_utils import generate_preview, _PREVIEW_EXTS
|
||||||
|
_logger = _log.getLogger(__name__)
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
generated = 0
|
generated = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
dirs_info = {}
|
||||||
|
|
||||||
for subdir in ("diary", "forum"):
|
for subdir in ("diary", "forum", "breeds", "breeds/gallery", "breeds/submissions"):
|
||||||
folder = os.path.join(MEDIA_DIR, subdir)
|
folder = os.path.join(MEDIA_DIR, subdir)
|
||||||
if not os.path.isdir(folder):
|
if not os.path.isdir(folder):
|
||||||
|
dirs_info[subdir] = "not found"
|
||||||
continue
|
continue
|
||||||
for fname in os.listdir(folder):
|
files = os.listdir(folder)
|
||||||
# Nur Original-Bilder (keine _preview, _thumb, Videos, PDFs)
|
dirs_info[subdir] = f"{len(files)} files"
|
||||||
|
for fname in files:
|
||||||
low = fname.lower()
|
low = fname.lower()
|
||||||
if "_preview" in low or "_thumb" in low:
|
if "_preview" in low or "_thumb" in low:
|
||||||
continue
|
continue
|
||||||
|
|
@ -1087,7 +1098,73 @@ async def generate_media_previews(user=Depends(require_admin)):
|
||||||
generated += 1
|
generated += 1
|
||||||
else:
|
else:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
_logger.warning(f"Preview None für {subdir}/{fname}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors += 1
|
errors += 1
|
||||||
|
_logger.error(f"Preview-Fehler {subdir}/{fname}: {exc}")
|
||||||
|
|
||||||
return {"generated": generated, "skipped": skipped, "errors": errors}
|
_logger.info(f"generate-previews: {generated} neu, {skipped} vorhanden, {errors} Fehler | dirs: {dirs_info}")
|
||||||
|
return {"generated": generated, "skipped": skipped, "errors": errors, "dirs": dirs_info}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/admin/upgrade-requests — offene Upgrade-Anfragen
|
||||||
|
# POST /api/admin/upgrade-requests/{id}/fulfill — Tier setzen + Mail
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/upgrade-requests")
|
||||||
|
async def list_upgrade_requests(user=Depends(require_admin)):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
||||||
|
u.name, u.email
|
||||||
|
FROM upgrade_requests r
|
||||||
|
JOIN users u ON u.id = r.user_id
|
||||||
|
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upgrade-requests/{req_id}/fulfill")
|
||||||
|
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
||||||
|
with db() as conn:
|
||||||
|
req = conn.execute(
|
||||||
|
"SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
|
||||||
|
(req_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(404, "Anfrage nicht gefunden.")
|
||||||
|
if req["fulfilled_at"]:
|
||||||
|
raise HTTPException(400, "Bereits erledigt.")
|
||||||
|
if req["tier"] not in _VALID_TIERS:
|
||||||
|
raise HTTPException(400, "Ungültiger Tier.")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET subscription_tier=? WHERE id=?",
|
||||||
|
(req["tier"], req["user_id"])
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE upgrade_requests SET fulfilled_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), fulfilled_by=? WHERE id=?",
|
||||||
|
(user["id"], req_id)
|
||||||
|
)
|
||||||
|
_audit(conn, user, "fulfill_upgrade", f"user:{req['user_id']}", f"tier={req['tier']}")
|
||||||
|
|
||||||
|
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
|
||||||
|
tier_label = tier_labels.get(req["tier"], req["tier"])
|
||||||
|
try:
|
||||||
|
from mailer import send_email, email_html
|
||||||
|
body_html = f"""
|
||||||
|
<p>Hallo {req['name']},</p>
|
||||||
|
<p>dein Account wurde soeben auf <strong>{tier_label}</strong> freigeschaltet.</p>
|
||||||
|
<p>Du kannst alle {tier_label}-Features ab sofort in der App nutzen.
|
||||||
|
Öffne Ban Yaro und lade die App einmal neu — dann ist dein neuer Tarif aktiv.</p>
|
||||||
|
<p>Vielen Dank für dein Vertrauen!</p>
|
||||||
|
<p>Viele Grüße<br>René & das Ban Yaro Team</p>"""
|
||||||
|
html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen")
|
||||||
|
plain = (f"Hallo {req['name']},\n\ndein Account wurde auf {tier_label} freigeschaltet.\n"
|
||||||
|
f"Öffne Ban Yaro und lade die App neu.\n\nViele Grüße\nRené")
|
||||||
|
await send_email(req["email"], f"Dein {tier_label}-Zugang ist aktiv", html, plain)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ async def me(user=Depends(get_current_user)):
|
||||||
profil_sichtbarkeit, avatar_url, created_at,
|
profil_sichtbarkeit, avatar_url, created_at,
|
||||||
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
|
preferred_theme, subscription_tier
|
||||||
FROM users WHERE id=?""",
|
FROM users WHERE id=?""",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -335,6 +335,46 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UpgradeRequestBody(BaseModel):
|
||||||
|
tier: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
@router.post("/upgrade-request")
|
||||||
|
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):
|
||||||
|
_VALID = {"pro", "breeder"}
|
||||||
|
if data.tier not in _VALID:
|
||||||
|
raise HTTPException(400, "Ungültiger Tarif.")
|
||||||
|
with db() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM upgrade_requests WHERE user_id=? AND tier=? AND fulfilled_at IS NULL",
|
||||||
|
(user["id"], data.tier)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return {"ok": True, "already": True}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO upgrade_requests (user_id, tier, message) VALUES (?,?,?)",
|
||||||
|
(user["id"], data.tier, data.message or None)
|
||||||
|
)
|
||||||
|
email = conn.execute("SELECT email FROM users WHERE id=?", (user["id"],)).fetchone()["email"]
|
||||||
|
|
||||||
|
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
|
||||||
|
tier_label = tier_labels[data.tier]
|
||||||
|
admin_email = os.getenv("ADMIN_EMAIL", "")
|
||||||
|
if admin_email:
|
||||||
|
try:
|
||||||
|
from routes.outreach import _send_smtp
|
||||||
|
subject = f"[Ban Yaro] Upgrade-Anfrage: {tier_label} — {user['name']}"
|
||||||
|
body = (f"Neue Upgrade-Anfrage:\n\n"
|
||||||
|
f"Nutzer: {user['name']} ({email})\n"
|
||||||
|
f"Tarif: {tier_label}\n"
|
||||||
|
f"Nachricht: {data.message or '—'}\n\n"
|
||||||
|
f"Admin-Panel: https://banyaro.app/#admin")
|
||||||
|
_send_smtp(admin_email, subject, body, "support")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-password")
|
@router.post("/reset-password")
|
||||||
async def reset_password(data: ResetPasswordRequest, request: Request):
|
async def reset_password(data: ResetPasswordRequest, request: Request):
|
||||||
rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw")
|
rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw")
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ async def breeder_apply(
|
||||||
stadt: str = Form(...),
|
stadt: str = Form(...),
|
||||||
website: str = Form(""),
|
website: str = Form(""),
|
||||||
beschreibung: str = Form(""),
|
beschreibung: str = Form(""),
|
||||||
dokument: UploadFile = File(...),
|
dokument: UploadFile = File(None),
|
||||||
user=Depends(get_current_user),
|
user=Depends(get_current_user),
|
||||||
):
|
):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
@ -103,28 +103,27 @@ async def breeder_apply(
|
||||||
if row["breeder_status"] == "pending":
|
if row["breeder_status"] == "pending":
|
||||||
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
|
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
|
||||||
|
|
||||||
# Dokument validieren und speichern
|
# Dokument optional speichern
|
||||||
data = await dokument.read()
|
filepath = None
|
||||||
if len(data) > 10 * 1024 * 1024:
|
if dokument and dokument.filename:
|
||||||
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
|
data = await dokument.read()
|
||||||
ext = os.path.splitext(dokument.filename or "")[1].lower()
|
if len(data) > 10 * 1024 * 1024:
|
||||||
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
|
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
|
||||||
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
|
ext = os.path.splitext(dokument.filename)[1].lower()
|
||||||
|
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
|
||||||
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
|
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
|
||||||
os.makedirs(user_doc_dir, exist_ok=True)
|
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
|
||||||
|
os.makedirs(user_doc_dir, exist_ok=True)
|
||||||
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
|
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
|
||||||
filepath = os.path.join(user_doc_dir, filename)
|
filepath = os.path.join(user_doc_dir, filename)
|
||||||
with open(filepath, "wb") as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET breeder_status='pending' WHERE id=?",
|
"UPDATE users SET breeder_status='pending' WHERE id=?",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
)
|
)
|
||||||
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
|
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?) "
|
"VALUES (?,?,?,?,?,?,?,?) "
|
||||||
|
|
@ -135,10 +134,11 @@ async def breeder_apply(
|
||||||
"verified_at=NULL",
|
"verified_at=NULL",
|
||||||
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
|
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
|
||||||
)
|
)
|
||||||
conn.execute(
|
if filepath:
|
||||||
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
|
conn.execute(
|
||||||
(user["id"], "antrag", filepath)
|
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
|
||||||
)
|
(user["id"], "antrag", filepath)
|
||||||
|
)
|
||||||
|
|
||||||
# Admin benachrichtigen
|
# Admin benachrichtigen
|
||||||
admin_body = f"""
|
admin_body = f"""
|
||||||
|
|
@ -183,6 +183,28 @@ async def admin_pending_breeders(admin=Depends(require_admin)):
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/admin/breeders — alle aktiven Züchter
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/admin/breeders")
|
||||||
|
async def admin_all_breeders(admin=Depends(require_admin)):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT u.id, u.name, u.email, u.created_at, u.subscription_tier,
|
||||||
|
u.breeder_status, u.last_login,
|
||||||
|
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
||||||
|
bp.stadt, bp.website, bp.verified_at,
|
||||||
|
(SELECT COUNT(*) FROM litters WHERE user_id=u.id) AS wuerfe_count,
|
||||||
|
(SELECT COUNT(*) FROM dogs WHERE user_id=u.id) AS hunde_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN breeder_profiles bp ON bp.user_id = u.id
|
||||||
|
WHERE u.rolle = 'breeder' OR u.breeder_status = 'approved'
|
||||||
|
ORDER BY CASE WHEN bp.verified_at IS NULL THEN 1 ELSE 0 END,
|
||||||
|
bp.verified_at DESC, u.created_at DESC
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
|
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
|
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
|
||||||
|
# Ownership bereits durch Dog-Check oben gesichert (dog gehört user)
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
|
|
@ -843,13 +844,14 @@ async def upload_photo(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user=Depends(get_current_user)
|
user=Depends(get_current_user)
|
||||||
):
|
):
|
||||||
# Hund gehört dem User?
|
# Hund gehört dem User? Altes Foto merken für späteres Löschen.
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
dog = conn.execute(
|
dog = conn.execute(
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
"SELECT id, foto_url FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
old_foto_url = dog["foto_url"]
|
||||||
|
|
||||||
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
|
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
|
||||||
import io
|
import io
|
||||||
|
|
@ -883,6 +885,15 @@ async def upload_photo(
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id))
|
conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id))
|
||||||
|
|
||||||
|
# Altes Foto von Disk löschen
|
||||||
|
if old_foto_url:
|
||||||
|
try:
|
||||||
|
old_path = safe_media_path(MEDIA_DIR, old_foto_url)
|
||||||
|
if old_path and os.path.isfile(old_path):
|
||||||
|
os.remove(old_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {"foto_url": foto_url}
|
return {"foto_url": foto_url}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -599,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=918"></script>
|
<script src="/js/api.js?v=919"></script>
|
||||||
<script src="/js/ui.js?v=918"></script>
|
<script src="/js/ui.js?v=919"></script>
|
||||||
<script src="/js/app.js?v=918"></script>
|
<script src="/js/app.js?v=919"></script>
|
||||||
<script src="/js/worlds.js?v=918"></script>
|
<script src="/js/worlds.js?v=919"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,9 @@ const API = (() => {
|
||||||
return get('/auth/me');
|
return get('/auth/me');
|
||||||
},
|
},
|
||||||
referral: () => get('/auth/referral'),
|
referral: () => get('/auth/referral'),
|
||||||
|
upgradeRequest(tier, message) {
|
||||||
|
return post('/auth/upgrade-request', { tier, message });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -688,6 +691,7 @@ const API = (() => {
|
||||||
updateProfile(data) { return put('/breeder/profile', data); },
|
updateProfile(data) { return put('/breeder/profile', data); },
|
||||||
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
|
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
|
||||||
pendingList() { return get('/admin/breeders/pending'); },
|
pendingList() { return get('/admin/breeders/pending'); },
|
||||||
|
allList() { return get('/admin/breeders'); },
|
||||||
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
|
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
|
||||||
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
|
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
|
||||||
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
|
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
|
||||||
|
|
@ -796,9 +800,17 @@ const API = (() => {
|
||||||
get(`/osm/pois?type=${type}&south=${south}&west=${west}&north=${north}&east=${east}&fast=true`),
|
get(`/osm/pois?type=${type}&south=${south}&west=${west}&north=${north}&east=${east}&fast=true`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SW-Cache-Einträge für eine URL löschen (z.B. nach Foto-Upload)
|
||||||
|
async function swCacheDelete(path) {
|
||||||
|
try {
|
||||||
|
const c = await caches.open('ban-yaro-api-v1');
|
||||||
|
await c.delete(new Request(path));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload, swCacheDelete,
|
||||||
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
|
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '918'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '939'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.1'; // ← 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
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ window.Page_admin = (() => {
|
||||||
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
||||||
{ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -90,6 +91,7 @@ window.Page_admin = (() => {
|
||||||
try { d = await API.get('/admin/action-items'); } catch { return; }
|
try { d = await API.get('/admin/action-items'); } catch { return; }
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' },
|
||||||
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
|
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
|
||||||
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
|
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
|
||||||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||||||
|
|
@ -163,6 +165,7 @@ window.Page_admin = (() => {
|
||||||
case 'hilfe': await _renderHilfe(el); break;
|
case 'hilfe': await _renderHilfe(el); break;
|
||||||
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;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||||
|
|
@ -1890,12 +1893,17 @@ window.Page_admin = (() => {
|
||||||
${UI.icon('arrows-clockwise')} Aktualisieren
|
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adm-zuchter-list">Lade…</div>
|
<div id="adm-zuchter-antraege">Lade…</div>
|
||||||
|
<div id="adm-zuchter-liste" style="margin-top:var(--space-4)">Lade…</div>
|
||||||
`;
|
`;
|
||||||
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () =>
|
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => {
|
||||||
_loadZuechterAntraege(el.querySelector('#adm-zuchter-list'))
|
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege'));
|
||||||
);
|
_loadZuechterListe(el.querySelector('#adm-zuchter-liste'));
|
||||||
await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list'));
|
});
|
||||||
|
await Promise.all([
|
||||||
|
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')),
|
||||||
|
_loadZuechterListe(el.querySelector('#adm-zuchter-liste')),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _loadZuechterAntraege(el) {
|
async function _loadZuechterAntraege(el) {
|
||||||
|
|
@ -1909,12 +1917,20 @@ window.Page_admin = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!antraege.length) {
|
if (!antraege.length) {
|
||||||
el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.');
|
el.innerHTML = `<div class="card" style="padding:var(--space-4)">
|
||||||
|
<div class="by-card-section-header">Offene Anträge</div>
|
||||||
|
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-sm);color:var(--c-text-muted)">
|
||||||
|
${UI.icon('check-circle')} Keine offenen Anträge
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
<div class="card" style="margin-bottom:0">
|
||||||
|
<div class="by-card-section-header">Offene Anträge (${antraege.length})</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-3)">
|
||||||
${antraege.map(a => `
|
${antraege.map(a => `
|
||||||
<div class="card" style="padding:var(--space-4)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
|
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
|
||||||
|
|
@ -2069,6 +2085,74 @@ window.Page_admin = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _loadZuechterListe(el) {
|
||||||
|
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||||
|
let breeders;
|
||||||
|
try {
|
||||||
|
breeders = await API.breeder.allList();
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierBadge = t => {
|
||||||
|
if (t === 'breeder') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#C4843A;color:#fff">Züchter-Abo</span>`;
|
||||||
|
if (t === 'breeder_test') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#aaa;color:#fff">Test</span>`;
|
||||||
|
return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#eee;color:#666">Standard</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = breeders.map(b => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">
|
||||||
|
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(b.name)}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(b.email)}</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${_esc(b.zwingername || '—')}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.rasse_text || '—')}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.stadt || '—')}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-size:var(--text-xs)">
|
||||||
|
${b.wuerfe_count || 0} Würfe<br>
|
||||||
|
<span style="color:var(--c-text-muted)">${b.hunde_count || 0} Hunde</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(b.subscription_tier)}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'}
|
||||||
|
</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">
|
||||||
|
<button class="btn btn-sm btn-ghost adm-breeder-tier-btn"
|
||||||
|
data-uid="${b.id}" data-name="${_esc(b.name)}" data-tier="${_esc(b.subscription_tier || 'standard')}"
|
||||||
|
style="font-size:var(--text-xs)">
|
||||||
|
Abo
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card adm-table-card">
|
||||||
|
<div class="by-card-section-header">Alle Züchter (${breeders.length})</div>
|
||||||
|
<div class="adm-table-scroll">
|
||||||
|
<table class="adm-table" style="width:100%;border-collapse:collapse">
|
||||||
|
<thead><tr>
|
||||||
|
${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h =>
|
||||||
|
`<th style="padding:var(--space-2) var(--space-3);text-align:left;
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-muted);font-weight:600;
|
||||||
|
border-bottom:1px solid var(--c-border);white-space:nowrap">${h}</th>`
|
||||||
|
).join('')}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows || `<tr><td colspan="8" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Züchter</td></tr>`}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
el.querySelectorAll('.adm-breeder-tier-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () =>
|
||||||
|
_changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
async function _renderJobs(el) {
|
async function _renderJobs(el) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
|
@ -3419,6 +3503,104 @@ window.Page_admin = (() => {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// TAB: UPGRADES
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async function _renderUpgrades(el) {
|
||||||
|
const rows = await API.get('/admin/upgrade-requests');
|
||||||
|
|
||||||
|
const tierBadge = t => {
|
||||||
|
const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] };
|
||||||
|
const [label, color] = cfg[t] || [t, '#888'];
|
||||||
|
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
|
||||||
|
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = rows.filter(r => !r.fulfilled_at);
|
||||||
|
const done = rows.filter(r => r.fulfilled_at);
|
||||||
|
|
||||||
|
const _row = (r, showBtn) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">${_esc(r.name)}<br>
|
||||||
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(r.email)}</span></td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
${r.message ? _esc(r.message) : '—'}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
${r.created_at?.slice(0,10) || ''}</td>
|
||||||
|
<td style="padding:var(--space-2) var(--space-3)">
|
||||||
|
${showBtn
|
||||||
|
? `<button class="btn btn-sm adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
|
||||||
|
style="background:#16a34a;color:#fff;border:none;padding:4px 12px;
|
||||||
|
border-radius:var(--radius-md);cursor:pointer;font-size:var(--text-xs);font-weight:600">
|
||||||
|
Freischalten
|
||||||
|
</button>`
|
||||||
|
: `<span style="font-size:var(--text-xs);color:var(--c-success)">✓ ${r.fulfilled_at?.slice(0,10)}</span>`}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
const thead = `<thead><tr>
|
||||||
|
${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
|
||||||
|
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
|
||||||
|
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
|
||||||
|
).join('')}</tr></thead>`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
|
||||||
|
<div class="by-card-section-header">Offene Anfragen (${pending.length})</div>
|
||||||
|
<div class="adm-table-scroll">
|
||||||
|
<table class="adm-table" style="width:100%;border-collapse:collapse">
|
||||||
|
${thead}
|
||||||
|
<tbody>
|
||||||
|
${pending.length
|
||||||
|
? pending.map(r => _row(r, true)).join('')
|
||||||
|
: `<tr><td colspan="5" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">
|
||||||
|
Keine offenen Anfragen
|
||||||
|
</td></tr>`}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${done.length ? `
|
||||||
|
<div class="card adm-table-card">
|
||||||
|
<div class="by-card-section-header">Erledigt (${done.length})</div>
|
||||||
|
<div class="adm-table-scroll">
|
||||||
|
<table class="adm-table" style="width:100%;border-collapse:collapse">
|
||||||
|
${thead}
|
||||||
|
<tbody>${done.map(r => _row(r, false)).join('')}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}`;
|
||||||
|
|
||||||
|
el.querySelectorAll('.adm-fulfill-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const { id, name, tier } = btn.dataset;
|
||||||
|
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
|
||||||
|
const ok = await UI.modal.confirm({
|
||||||
|
title: `${name} auf ${tierLabel} freischalten?`,
|
||||||
|
body: `<p style="font-size:var(--text-sm)">
|
||||||
|
Der Account wird auf <strong>${tierLabel}</strong> gesetzt und
|
||||||
|
eine Bestätigungsmail gesendet.
|
||||||
|
</p>`,
|
||||||
|
confirmLabel: 'Freischalten',
|
||||||
|
danger: false,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
|
||||||
|
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
|
||||||
|
_renderTab();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast.error(e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Freischalten';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -769,8 +769,16 @@ window.Page_dog_profile = (() => {
|
||||||
await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0);
|
await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0);
|
||||||
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 };
|
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 };
|
||||||
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
|
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
|
||||||
|
// localStorage + SW-Cache invalidieren
|
||||||
|
const userId2 = _appState.user?.id || 'anon';
|
||||||
|
localStorage.removeItem(`w3_bg3_${userId2}_` + new Date().toISOString().slice(0, 10));
|
||||||
|
localStorage.removeItem('w3_dogs');
|
||||||
|
API.swCacheDelete('/api/dogs');
|
||||||
|
API.swCacheDelete(`/api/dogs/${dog.id}`);
|
||||||
|
API.swCacheDelete(`/api/dogs/${dog.id}/welcome-dashboard`);
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
App.renderDogSwitcher();
|
App.renderDogSwitcher?.();
|
||||||
|
window.Worlds?.refresh(_appState);
|
||||||
UI.toast.success('Foto hochgeladen.');
|
UI.toast.success('Foto hochgeladen.');
|
||||||
_renderProfile(_appState.activeDog);
|
_renderProfile(_appState.activeDog);
|
||||||
// Editor neu öffnen damit User positionieren kann
|
// Editor neu öffnen damit User positionieren kann
|
||||||
|
|
@ -1354,6 +1362,9 @@ window.Page_dog_profile = (() => {
|
||||||
fell_typ: fd.fell_typ || null,
|
fell_typ: fd.fell_typ || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Datei-Referenz VOR Modal-Close sichern — DOM-Element wird beim Schließen entfernt
|
||||||
|
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
|
||||||
|
|
||||||
let saved;
|
let saved;
|
||||||
if (dog) {
|
if (dog) {
|
||||||
saved = await API.dogs.update(dog.id, payload);
|
saved = await API.dogs.update(dog.id, payload);
|
||||||
|
|
@ -1371,7 +1382,6 @@ window.Page_dog_profile = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Foto hochladen wenn gewählt
|
// Foto hochladen wenn gewählt
|
||||||
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
|
|
||||||
if (fotoFile) {
|
if (fotoFile) {
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|
@ -1380,6 +1390,13 @@ window.Page_dog_profile = (() => {
|
||||||
saved.foto_url = result.foto_url;
|
saved.foto_url = result.foto_url;
|
||||||
_appState.activeDog = { ...saved };
|
_appState.activeDog = { ...saved };
|
||||||
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
|
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
|
||||||
|
// localStorage + SW-Cache invalidieren damit Welten das neue Foto zeigen
|
||||||
|
const userId = _appState.user?.id || 'anon';
|
||||||
|
localStorage.removeItem(`w3_bg3_${userId}_` + new Date().toISOString().slice(0, 10));
|
||||||
|
localStorage.removeItem('w3_dogs');
|
||||||
|
API.swCacheDelete('/api/dogs');
|
||||||
|
API.swCacheDelete(`/api/dogs/${saved.id}`);
|
||||||
|
API.swCacheDelete(`/api/dogs/${saved.id}/welcome-dashboard`);
|
||||||
} catch {
|
} catch {
|
||||||
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
|
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
|
||||||
}
|
}
|
||||||
|
|
@ -1388,6 +1405,9 @@ window.Page_dog_profile = (() => {
|
||||||
// Dog Switcher in Header + Sidebar aktualisieren
|
// Dog Switcher in Header + Sidebar aktualisieren
|
||||||
App.renderDogSwitcher?.();
|
App.renderDogSwitcher?.();
|
||||||
|
|
||||||
|
// Welten neu laden damit HUND-Welt den neuen Hund zeigt
|
||||||
|
window.Worlds?.refresh(_appState);
|
||||||
|
|
||||||
await _render();
|
await _render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -205,8 +205,10 @@ window.Page_map = (() => {
|
||||||
|
|
||||||
<div class="map-fabs">
|
<div class="map-fabs">
|
||||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||||
|
${App.hasPro(_appState?.user) ? `
|
||||||
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
||||||
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
||||||
|
` : ''}
|
||||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -289,8 +291,8 @@ window.Page_map = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||||
document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar);
|
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
|
||||||
document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp);
|
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,269 @@ window.Page_settings = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// ABO & TARIF
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _tierCard(u) {
|
||||||
|
const tier = u.subscription_tier || 'standard';
|
||||||
|
const rolle = u.rolle || 'user';
|
||||||
|
const isAdmin = rolle === 'admin' || rolle === 'moderator';
|
||||||
|
const isPro = ['pro','pro_test'].includes(tier);
|
||||||
|
const isBreeder = ['breeder','breeder_test'].includes(tier) || rolle === 'breeder';
|
||||||
|
const isStandard = !isAdmin && !isPro && !isBreeder;
|
||||||
|
|
||||||
|
const _badge = (label, color) =>
|
||||||
|
`<span style="display:inline-block;padding:2px 10px;border-radius:20px;
|
||||||
|
font-size:var(--text-xs);font-weight:700;letter-spacing:.03em;
|
||||||
|
background:${color};color:#fff">${label}</span>`;
|
||||||
|
|
||||||
|
const _upgradeBtn = (id, label, price, color) =>
|
||||||
|
`<button id="${id}"
|
||||||
|
style="flex:1;min-width:130px;padding:var(--space-3) var(--space-2);
|
||||||
|
border-radius:var(--radius-md);border:none;cursor:pointer;
|
||||||
|
background:${color};color:#fff;
|
||||||
|
font-size:var(--text-sm);font-weight:600;
|
||||||
|
display:flex;flex-direction:column;align-items:center;gap:2px">
|
||||||
|
<span>${label}</span>
|
||||||
|
<span style="font-size:var(--text-xs);font-weight:400;opacity:.9">${price}</span>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
let statusHtml = '';
|
||||||
|
let actionsHtml = '';
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
statusHtml = _badge('Admin', '#6366f1');
|
||||||
|
} else if (isBreeder) {
|
||||||
|
statusHtml = _badge('Züchter aktiv', '#C4843A');
|
||||||
|
} else if (isPro) {
|
||||||
|
statusHtml = _badge('Pro aktiv', '#16a34a');
|
||||||
|
actionsHtml = `
|
||||||
|
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||||
|
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A')}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
statusHtml = _badge('Kostenlos', '#888');
|
||||||
|
actionsHtml = `
|
||||||
|
<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||||
|
${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')}
|
||||||
|
${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card" style="margin-bottom:var(--space-4)">
|
||||||
|
<div class="by-card-section-header">Abo & Tarif</div>
|
||||||
|
<div style="padding:var(--space-4)">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||||||
|
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">Aktueller Tarif:</span>
|
||||||
|
${statusHtml}
|
||||||
|
</div>
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showUpgradeModal(tier) {
|
||||||
|
const isPro = tier === 'pro';
|
||||||
|
const label = isPro ? 'Ban Yaro Pro' : 'Züchter';
|
||||||
|
const price = isPro ? '29 €/Jahr' : '49 €/Jahr';
|
||||||
|
const color = isPro ? '#16a34a' : '#C4843A';
|
||||||
|
const _group = (title, items) => `
|
||||||
|
<div style="margin-bottom:var(--space-3)">
|
||||||
|
<div style="font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:var(--c-text-muted);margin-bottom:var(--space-2)">${title}</div>
|
||||||
|
${items.map(f => `
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:var(--space-2);
|
||||||
|
padding:3px 0;font-size:var(--text-sm)">
|
||||||
|
<span style="color:${color};font-weight:700;flex-shrink:0;margin-top:1px">✓</span>
|
||||||
|
<span>${f}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const featureList = isPro
|
||||||
|
? _group('Deine Hunde', [
|
||||||
|
'Bis zu 10 Hunde gleichzeitig verwalten',
|
||||||
|
'Getrennte Trainingsfortschritte, Gesundheits- und Ernährungsdaten je Hund',
|
||||||
|
])
|
||||||
|
+ _group('Community & Alltag', [
|
||||||
|
'Gassi-Treffen: Fotos und Rasse der Teilnehmer sichtbar, Fotos nach dem Treffen hochladen',
|
||||||
|
'Direktnachrichten & Chat mit anderen Hundebesitzern',
|
||||||
|
'Playdate: Spielkameraden in der Nähe finden und verabreden',
|
||||||
|
])
|
||||||
|
+ _group('Tools & Wissen', [
|
||||||
|
'Ernährung: Kalorienbedarf-Rechner, BARF-Guide, Giftliste, KI-Ernährungsberater',
|
||||||
|
'Reise-Checkliste & EU-Länder-Einreiseregeln',
|
||||||
|
'Notizblock mit KI-Muster-Analyse',
|
||||||
|
'Erweiterte Karten-Layer (Wandern, Radfahren, Satellit)',
|
||||||
|
'Alle künftigen Pro-Features inklusive',
|
||||||
|
])
|
||||||
|
: `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
|
background:rgba(22,163,74,0.08);border:1px solid rgba(22,163,74,0.2);
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||||||
|
<strong style="color:#16a34a">✓ Alle Pro-Features inklusive</strong> —
|
||||||
|
mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten-Layer
|
||||||
|
</div>`
|
||||||
|
+ _group('Zucht-Management', [
|
||||||
|
'Zuchtkartei: Stammdaten, Gesundheitstests (HD, ED, OCD, Augen, Herz, Patella, ZTP), Gentests (MDR1, PRA, DM, vWD)',
|
||||||
|
'Wurfverwaltung: Welpen, Gewichtsverlauf, Fotos, automatisch ausgefüllter Kaufvertrag',
|
||||||
|
'Warteliste: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin',
|
||||||
|
'Läufigkeit & Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteinberechnung',
|
||||||
|
'Wurf-Buchstabe und Wurf-Name (z. B. A-Wurf, „Vatertags-Wurf")',
|
||||||
|
])
|
||||||
|
+ _group('KI & Analyse', [
|
||||||
|
'KI-Züchter-Assistent: Wurfankündigungen schreiben, Genetik-Erklärung für Käufer, Paarungsanalyse',
|
||||||
|
'Stammbaum bis 4 Generationen mit klickbaren Knoten',
|
||||||
|
'Inzucht-Koeffizient (Wright\'s Formel, Ampel-Bewertung, Probeverpaarung)',
|
||||||
|
'Tierschutz-Check automatisch bei jeder Verpaarung',
|
||||||
|
'KI-Jahresbericht mit Trends und Empfehlungen',
|
||||||
|
])
|
||||||
|
+ _group('Sichtbarkeit & Export', [
|
||||||
|
'Öffentliches Züchter-Profil unter banyaro.app/breeder/{zwingername}',
|
||||||
|
'Wurfbörse: Würfe öffentlich ankündigen, Käufer schreiben direkt an',
|
||||||
|
'Datenexport als HTML-Dossier und ODS-Tabelle (LibreOffice / Excel)',
|
||||||
|
'Privater Züchter-Bereich mit Zwingername und Logo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
|
||||||
|
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)`;
|
||||||
|
|
||||||
|
const breederForm = isPro ? '' : `
|
||||||
|
<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||||||
|
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||||
|
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-3)">
|
||||||
|
Dein Zwinger
|
||||||
|
</div>
|
||||||
|
<form id="breeder-upgrade-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
|
||||||
|
Zwingername <span style="color:var(--c-danger)">*</span>
|
||||||
|
</label>
|
||||||
|
<input name="zwingername" type="text" maxlength="100" required
|
||||||
|
placeholder="z. B. vom Sonnenfeld" style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
|
||||||
|
Rasse <span style="color:var(--c-danger)">*</span>
|
||||||
|
</label>
|
||||||
|
<input name="rasse_text" type="text" maxlength="100" required
|
||||||
|
placeholder="z. B. Labrador Retriever" style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Zuchtverein</label>
|
||||||
|
<input name="verein" type="text" maxlength="100"
|
||||||
|
placeholder="z. B. VDH, BCD" style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">Stadt</label>
|
||||||
|
<input name="stadt" type="text" maxlength="80"
|
||||||
|
placeholder="z. B. München" style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||||
|
<input name="vdh_mitglied" type="checkbox" id="upg-breeder-vdh"
|
||||||
|
style="width:16px;height:16px;cursor:pointer;flex-shrink:0">
|
||||||
|
<label for="upg-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:4px">
|
||||||
|
Dokument hochladen <span style="font-weight:400;color:var(--c-text-muted)">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input name="dokument" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||||
|
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||||||
|
Zuchtbuch, Vereinsausweis o.ä. — kann auch per E-Mail nachgereicht werden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
UI.modal.open({
|
||||||
|
title: `${label} freischalten`,
|
||||||
|
body: `
|
||||||
|
<div style="padding:var(--space-2) 0">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||||
|
<div style="font-size:2rem;font-weight:800;color:${color}">${price}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||||||
|
Einmaliger Jahresbeitrag<br>Kündigung jederzeit möglich
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:0 0 var(--space-3)">
|
||||||
|
${featureList}
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--space-3);border-radius:var(--radius-md);
|
||||||
|
background:var(--c-surface-raised,rgba(0,0,0,.04));
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6">
|
||||||
|
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
||||||
|
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
||||||
|
</div>
|
||||||
|
${breederForm}
|
||||||
|
</div>`,
|
||||||
|
footer: `
|
||||||
|
<button data-modal-close
|
||||||
|
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||||||
|
border:1.5px solid var(--c-border);background:transparent;
|
||||||
|
color:var(--c-text);font-size:var(--text-sm);cursor:pointer">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button id="upgrade-request-send-btn"
|
||||||
|
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
|
||||||
|
border:none;cursor:pointer;background:${color};color:#fff;
|
||||||
|
font-size:var(--text-sm);font-weight:600">
|
||||||
|
Anfrage senden
|
||||||
|
</button>`
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('upgrade-request-send-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// Züchter: Formular validieren + als FormData senden
|
||||||
|
if (!isPro) {
|
||||||
|
const form = document.getElementById('breeder-upgrade-form');
|
||||||
|
if (form && !form.reportValidity()) return;
|
||||||
|
if (form) {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
|
||||||
|
// Pflichtfelder aus Form übernehmen falls leer → leere Strings senden
|
||||||
|
if (!fd.get('verein')) fd.set('verein', '');
|
||||||
|
if (!fd.get('stadt')) fd.set('stadt', '');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird gesendet…';
|
||||||
|
try {
|
||||||
|
await API.breeder.apply(fd);
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('bereits')) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Anfrage senden';
|
||||||
|
UI.toast.error(e.message || 'Fehler beim Einreichen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird gesendet…';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.auth.upgradeRequest(tier);
|
||||||
|
UI.modal.close();
|
||||||
|
if (res.already) {
|
||||||
|
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
|
||||||
|
} else {
|
||||||
|
UI.toast.success('Anfrage gesendet! Wir melden uns per E-Mail.');
|
||||||
|
}
|
||||||
|
if (!isPro) _loadBreederCard();
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Anfrage senden';
|
||||||
|
UI.toast.error(e.message || 'Fehler beim Senden.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// RENDER
|
// RENDER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -276,13 +539,6 @@ window.Page_settings = (() => {
|
||||||
<span>Feedback geben</span>
|
<span>Feedback geben</span>
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
</div>
|
</div>
|
||||||
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
|
|
||||||
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
|
|
||||||
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
|
|
||||||
font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
||||||
⭐ <strong>Ban Yaro Pro</strong> kommt bald — mehr Features, mehrere Hunde.
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div style="padding:var(--space-3) var(--space-4);border-top:1px solid var(--c-border)">
|
<div style="padding:var(--space-3) var(--space-4);border-top:1px solid var(--c-border)">
|
||||||
<button id="settings-logout-btn"
|
<button id="settings-logout-btn"
|
||||||
style="width:100%;display:flex;align-items:center;justify-content:center;
|
style="width:100%;display:flex;align-items:center;justify-content:center;
|
||||||
|
|
@ -316,6 +572,8 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${_tierCard(u)}
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:var(--space-4)">
|
<div class="card" style="margin-bottom:var(--space-4)">
|
||||||
<div class="by-card-section-header">
|
<div class="by-card-section-header">
|
||||||
App-Einstellungen
|
App-Einstellungen
|
||||||
|
|
@ -355,7 +613,15 @@ window.Page_settings = (() => {
|
||||||
• Vollbild: <em>Einstellungen → Display → Navigationsleiste → Wischgesten</em> aktivieren.
|
• Vollbild: <em>Einstellungen → Display → Navigationsleiste → Wischgesten</em> aktivieren.
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<!-- KI-Notiz-Assistent -->
|
<!-- KI-Notiz-Assistent (nur Pro) -->
|
||||||
|
${(() => {
|
||||||
|
const tier = u.subscription_tier || 'standard';
|
||||||
|
const hasPro = ['pro','breeder'].includes(tier) ||
|
||||||
|
u.rolle === 'admin' || u.rolle === 'moderator' ||
|
||||||
|
u.is_moderator || u.is_social_media ||
|
||||||
|
tier.endsWith('_test');
|
||||||
|
if (!hasPro) return '';
|
||||||
|
return `
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
|
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
|
||||||
<div class="settings-toggle-label">
|
<div class="settings-toggle-label">
|
||||||
|
|
@ -377,7 +643,8 @@ window.Page_settings = (() => {
|
||||||
background:#fff;transition:.2s;
|
background:#fff;transition:.2s;
|
||||||
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>`;
|
||||||
|
})()}
|
||||||
|
|
||||||
<!-- Goldene Gassi-Stunde -->
|
<!-- Goldene Gassi-Stunde -->
|
||||||
<div class="settings-toggle-row" style="border-bottom:none">
|
<div class="settings-toggle-row" style="border-bottom:none">
|
||||||
|
|
@ -829,6 +1096,13 @@ window.Page_settings = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-upgrade-pro-btn')?.addEventListener('click', () => {
|
||||||
|
_showUpgradeModal('pro');
|
||||||
|
});
|
||||||
|
document.getElementById('settings-upgrade-breeder-btn')?.addEventListener('click', () => {
|
||||||
|
_showUpgradeModal('breeder');
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
|
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
|
||||||
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
|
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
|
||||||
else if (window.Worlds) window.Worlds.openConfig?.();
|
else if (window.Worlds) window.Worlds.openConfig?.();
|
||||||
|
|
@ -1167,17 +1441,17 @@ window.Page_settings = (() => {
|
||||||
</span>`;
|
</span>`;
|
||||||
actionBlock = `
|
actionBlock = `
|
||||||
<div style="margin-top:var(--space-3)">
|
<div style="margin-top:var(--space-3)">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-2)">
|
||||||
|
Du kannst einen neuen Antrag stellen.
|
||||||
|
</p>
|
||||||
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
|
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
|
||||||
${UI.icon('arrow-counter-clockwise')} Neu beantragen
|
${UI.icon('arrow-counter-clockwise')} Neu beantragen
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
actionBlock = `
|
// Kein Antrag, kein Profil — Card ausblenden (Upgrade-Flow läuft über Abo & Tarif)
|
||||||
<div style="margin-top:var(--space-3)">
|
slot.innerHTML = '';
|
||||||
<button class="btn btn-primary btn-sm" id="breeder-apply-btn">
|
return;
|
||||||
${UI.icon('certificate')} Züchter werden
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slot.innerHTML = `
|
slot.innerHTML = `
|
||||||
|
|
@ -1190,11 +1464,7 @@ window.Page_settings = (() => {
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Button-Handler binden
|
// Button-Handler binden
|
||||||
const applyBtn = slot.querySelector('#breeder-apply-btn');
|
slot.querySelector('#breeder-reapply-btn')?.addEventListener('click', () => _showUpgradeModal('breeder'));
|
||||||
const reapplyBtn = slot.querySelector('#breeder-reapply-btn');
|
|
||||||
if (applyBtn || reapplyBtn) {
|
|
||||||
(applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal());
|
|
||||||
}
|
|
||||||
|
|
||||||
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
|
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
|
||||||
_openBreederEditModal(profile)
|
_openBreederEditModal(profile)
|
||||||
|
|
|
||||||
|
|
@ -384,8 +384,13 @@ window.Page_wiki = (() => {
|
||||||
|
|
||||||
function _breedCardHtml(r) {
|
function _breedCardHtml(r) {
|
||||||
const fotoUrl = r.foto_url || r.user_foto || '';
|
const fotoUrl = r.foto_url || r.user_foto || '';
|
||||||
|
// Für lokale Bilder: _preview.webp zuerst, bei Fehler Original nachladen
|
||||||
|
const srcUrl = fotoUrl.startsWith('/media/')
|
||||||
|
? fotoUrl.replace(/\.(jpe?g|png|gif|webp)$/i, '_preview.webp')
|
||||||
|
: fotoUrl;
|
||||||
const photoHtml = fotoUrl
|
const photoHtml = fotoUrl
|
||||||
? `<img class="wiki-breed-photo" src="${_esc(fotoUrl)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
? `<img class="wiki-breed-photo" src="${_esc(srcUrl)}" loading="lazy" alt="${_esc(r.name)}"
|
||||||
|
onerror="if(this.src.includes('_preview')){this.src='${_esc(fotoUrl)}'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}">`
|
||||||
: '';
|
: '';
|
||||||
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
|
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
|
||||||
|
|
||||||
|
|
@ -746,7 +751,9 @@ window.Page_wiki = (() => {
|
||||||
${allFotos.map((f, i) => `
|
${allFotos.map((f, i) => `
|
||||||
<button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}"
|
<button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}"
|
||||||
aria-label="Foto ${i + 1}">
|
aria-label="Foto ${i + 1}">
|
||||||
<img src="${_esc(f.foto_url)}" alt="" loading="lazy">
|
<img src="${_esc(f.foto_url.startsWith('/media/') ? f.foto_url.replace(/\.(jpe?g|png|gif|webp)$/i,'_preview.webp') : f.foto_url)}"
|
||||||
|
alt="" loading="lazy"
|
||||||
|
onerror="if(this.src.includes('_preview')){this.src='${_esc(f.foto_url)}'}else{this.style.display='none'}">
|
||||||
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''}
|
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''}
|
||||||
</button>`).join('')}
|
</button>`).join('')}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@
|
||||||
|
|
||||||
window.Worlds = (() => {
|
window.Worlds = (() => {
|
||||||
|
|
||||||
let _state = null;
|
let _state = null;
|
||||||
let _cur = 1; // 0=JETZT 1=HUND 2=WELT
|
let _cur = 1; // 0=JETZT 1=HUND 2=WELT
|
||||||
let _visible = false;
|
let _visible = false;
|
||||||
let _map = null;
|
let _map = null;
|
||||||
let _weltInited = false;
|
let _weltInited = false;
|
||||||
|
let _refreshPending = false; // gesetzt wenn refresh() während !_visible aufgerufen wird
|
||||||
let _lastUserId = undefined;
|
let _lastUserId = undefined;
|
||||||
let _dogs = []; // gecachte Hundesliste
|
let _dogs = []; // gecachte Hundesliste
|
||||||
let _dogIdx = 0; // aktuell angezeigter Hund
|
let _dogIdx = 0; // aktuell angezeigter Hund
|
||||||
|
|
@ -118,6 +119,14 @@ window.Worlds = (() => {
|
||||||
if (worldIdx != null) _goTo(worldIdx, false);
|
if (worldIdx != null) _goTo(worldIdx, false);
|
||||||
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
|
if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); }
|
||||||
|
|
||||||
|
// Ausstehender Refresh (z.B. nach Foto-Upload während Worlds unsichtbar)
|
||||||
|
if (_refreshPending) {
|
||||||
|
_refreshPending = false;
|
||||||
|
_renderJetzt();
|
||||||
|
_renderHund();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Nach Login/Logout: Config aus DB laden, dann rendern
|
// Nach Login/Logout: Config aus DB laden, dann rendern
|
||||||
const currentUserId = _state?.user?.id ?? null;
|
const currentUserId = _state?.user?.id ?? null;
|
||||||
if (currentUserId !== _lastUserId) {
|
if (currentUserId !== _lastUserId) {
|
||||||
|
|
@ -347,7 +356,6 @@ window.Worlds = (() => {
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||||||
${chips.map(c => `
|
${chips.map(c => `
|
||||||
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
|
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
|
||||||
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>` : ''}
|
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -374,6 +382,22 @@ window.Worlds = (() => {
|
||||||
${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)">
|
${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||||
Alle Funktionen sind bereits in deinen Welten sichtbar.
|
Alle Funktionen sind bereits in deinen Welten sichtbar.
|
||||||
</div>`}
|
</div>`}
|
||||||
|
<div style="margin-top:16px;padding:12px 14px;border-radius:12px;
|
||||||
|
background:var(--c-surface-raised,rgba(0,0,0,.04));
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6;
|
||||||
|
display:flex;align-items:flex-start;gap:10px">
|
||||||
|
<svg class="ph-icon" style="width:1rem;height:1rem;flex-shrink:0;margin-top:1px;color:var(--c-primary)">
|
||||||
|
<use href="/icons/phosphor.svg#info"></use>
|
||||||
|
</svg>
|
||||||
|
<span>Einzelne Funktionen ausblenden oder zwischen den Welten verschieben:
|
||||||
|
<button id="fab-all-goto-worlds" style="background:none;border:none;padding:0;cursor:pointer;
|
||||||
|
color:var(--c-primary);font-size:inherit;font-family:inherit;font-weight:600;
|
||||||
|
text-decoration:underline;text-underline-offset:2px">
|
||||||
|
Welten bearbeiten
|
||||||
|
</button>
|
||||||
|
in deinem Profil.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -388,6 +412,10 @@ window.Worlds = (() => {
|
||||||
navigateTo(btn.dataset.page);
|
navigateTo(btn.dataset.page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ov.querySelector('#fab-all-goto-worlds')?.addEventListener('click', () => {
|
||||||
|
_close();
|
||||||
|
_openConfigModal();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SCHNELL-GASSI ─────────────────────────────────────────────
|
// ── SCHNELL-GASSI ─────────────────────────────────────────────
|
||||||
|
|
@ -673,6 +701,7 @@ window.Worlds = (() => {
|
||||||
let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
|
let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
|
||||||
let _drag = null; // { page, fromWorld, ghost }
|
let _drag = null; // { page, fromWorld, ghost }
|
||||||
|
|
||||||
|
const isAdmin = _state?.user?.rolle === 'admin';
|
||||||
const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' };
|
const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' };
|
||||||
const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' };
|
const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' };
|
||||||
const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]);
|
const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]);
|
||||||
|
|
@ -776,7 +805,8 @@ window.Worlds = (() => {
|
||||||
<use href="/icons/phosphor.svg#lock-simple"></use>
|
<use href="/icons/phosphor.svg#lock-simple"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`}
|
</div>`}
|
||||||
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
|
${isAdmin && c.pro ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
|
||||||
|
${isAdmin && c.role === 'breeder' ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">Z</span>` : ''}
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -923,7 +953,8 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
async function _loadDailyImage(dog) {
|
async function _loadDailyImage(dog) {
|
||||||
if (!dog) return null;
|
if (!dog) return null;
|
||||||
const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10);
|
const userId = _state?.user?.id || 'anon';
|
||||||
|
const todayKey = `bg3_${userId}_` + new Date().toISOString().slice(0, 10);
|
||||||
const cached = _wLoad(todayKey);
|
const cached = _wLoad(todayKey);
|
||||||
if (cached?.data) return cached.data;
|
if (cached?.data) return cached.data;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1157,7 +1188,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Deine Bereiche</div>
|
<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, f.pro && _isRoleBasedPro(), f.role === 'breeder')).join('')}
|
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="impressum">Impressum</span>
|
<span data-wnav="impressum">Impressum</span>
|
||||||
|
|
@ -1255,12 +1286,12 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
if (!dogs.length) {
|
if (!dogs.length) {
|
||||||
const features = [
|
const features = [
|
||||||
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
|
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
|
||||||
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
|
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
|
||||||
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
|
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
|
||||||
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
|
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
|
||||||
{ icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' },
|
{ icon:'scales', color:'#3B82F6', title:'Wurfbörse', sub:'Welpen finden' },
|
||||||
{ icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' },
|
{ icon:'currency-eur', color:'#06B6D4', title:'Ausgaben', sub:'Budget im Blick' },
|
||||||
];
|
];
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="world-top">
|
<div class="world-top">
|
||||||
|
|
@ -1448,7 +1479,7 @@ window.Worlds = (() => {
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="world-section-label">Alles über ${_esc(dog.name)}</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, c.pro && _isRoleBasedPro(), c.role === 'breeder')).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>
|
||||||
|
|
@ -1622,7 +1653,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Die Welt da draußen</div>
|
<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, c.pro && _isRoleBasedPro(), c.role === 'breeder')).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>
|
||||||
|
|
@ -1688,6 +1719,19 @@ window.Worlds = (() => {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { init, show, hide, navigateTo, openConfig: _openConfigModal, get _visible() { return _visible; } };
|
function refresh(appState) {
|
||||||
|
if (appState) _state = appState;
|
||||||
|
localStorage.removeItem('w3_dogs');
|
||||||
|
_bgUrl = null;
|
||||||
|
if (_visible) {
|
||||||
|
if (_cur === 0) _renderJetzt();
|
||||||
|
else if (_cur === 1) _renderHund();
|
||||||
|
else _renderWelt();
|
||||||
|
} else {
|
||||||
|
_refreshPending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, show, hide, navigateTo, refresh, openConfig: _openConfigModal, get _visible() { return _visible; } };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,38 @@
|
||||||
"@type": "MobileApplication",
|
"@type": "MobileApplication",
|
||||||
"name": "Ban Yaro",
|
"name": "Ban Yaro",
|
||||||
"alternateName": "Ban Yaro — Die Hunde-Plattform",
|
"alternateName": "Ban Yaro — Die Hunde-Plattform",
|
||||||
"description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App für Hundebesitzer und Züchter. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check, Giftköder-Alarm, Gassi-Community — DSGVO-konform, ohne App Store.",
|
"description": "Ban Yaro ist die deutschsprachige All-in-One Hunde-Plattform für Hundebesitzer und Züchter. Kostenlos: Tagebuch, Impfpass, Gassi-Community, Giftköder-Alarm. Pro (29 €/Jahr): mehrere Hunde, Ernährung. Züchter (49 €/Jahr): Warteliste, Läufigkeit, Wurfverwaltung, Stammbaum, Inzucht-Koeffizient, KI-Assistent — DSGVO-konform, ohne App Store.",
|
||||||
"url": "https://banyaro.app",
|
"url": "https://banyaro.app",
|
||||||
"applicationCategory": "LifestyleApplication",
|
"applicationCategory": "LifestyleApplication",
|
||||||
"applicationSubCategory": "PetApplication",
|
"applicationSubCategory": "PetApplication",
|
||||||
"operatingSystem": "iOS, Android, Web",
|
"operatingSystem": "iOS, Android, Web",
|
||||||
"inLanguage": "de",
|
"inLanguage": "de",
|
||||||
"availableOnDevice": "Smartphone, Tablet",
|
"availableOnDevice": "Smartphone, Tablet",
|
||||||
"offers": {
|
"offers": [
|
||||||
"@type": "Offer",
|
{
|
||||||
"price": "0",
|
"@type": "Offer",
|
||||||
"priceCurrency": "EUR",
|
"name": "Kostenlos",
|
||||||
"availability": "https://schema.org/InStock"
|
"price": "0",
|
||||||
},
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/InStock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"name": "Ban Yaro Pro",
|
||||||
|
"price": "29",
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"description": "Mehrere Hunde, Ernährung, erweiterte Karten-Layer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"name": "Züchter",
|
||||||
|
"price": "49",
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"description": "Vollständige Züchter-Plattform: Warteliste, Läufigkeit, Wurfverwaltung, Stammbaum, IK-Rechner, KI-Assistent"
|
||||||
|
}
|
||||||
|
],
|
||||||
"publisher": {
|
"publisher": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "Ban Yaro",
|
"name": "Ban Yaro",
|
||||||
|
|
@ -108,12 +127,18 @@
|
||||||
"DSGVO Datenexport (Art. 20): vollständiger JSON-Download aller eigenen Daten",
|
"DSGVO Datenexport (Art. 20): vollständiger JSON-Download aller eigenen Daten",
|
||||||
"Hunde-Persönlichkeitstest mit Trainingstipps",
|
"Hunde-Persönlichkeitstest mit Trainingstipps",
|
||||||
"Reise-Checkliste und EU-Länder-Einreiseregeln",
|
"Reise-Checkliste und EU-Länder-Einreiseregeln",
|
||||||
"Integrierte Hilfe und FAQ ohne App Store"
|
"Integrierte Hilfe und FAQ ohne App Store",
|
||||||
|
"Warteliste: Interessenten mit Präferenzen pro Zuchthündin verwalten",
|
||||||
|
"Läufigkeit und Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, Meilensteine",
|
||||||
|
"Wurf-Buchstabe und Wurf-Name für jeden Wurf",
|
||||||
|
"Privater Züchter-Bereich mit Logo und Zwingername im Header",
|
||||||
|
"Züchter-Profilfotos und Galerie auf der öffentlichen Visitenkarte",
|
||||||
|
"Züchter-Kacheln in HUND-Welt mit Z-Badge erkennbar"
|
||||||
],
|
],
|
||||||
"screenshot": "https://banyaro.app/icons/icon-512.png",
|
"screenshot": "https://banyaro.app/icons/icon-512.png",
|
||||||
"softwareVersion": "1.5.1",
|
"softwareVersion": "1.5.1",
|
||||||
"datePublished": "2026-05-01",
|
"datePublished": "2026-05-01",
|
||||||
"dateModified": "2026-05-12",
|
"dateModified": "2026-05-14",
|
||||||
"areaServed": ["DE", "AT", "CH"],
|
"areaServed": ["DE", "AT", "CH"],
|
||||||
"audience": {
|
"audience": {
|
||||||
"@type": "Audience",
|
"@type": "Audience",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Ban Yaro — Die deutschsprachige Hunde-Plattform
|
# Ban Yaro — Die deutschsprachige Hunde-Plattform für Hundebesitzer und Züchter
|
||||||
# https://banyaro.app
|
# https://banyaro.app
|
||||||
# Letzte Aktualisierung: 2026-05-12
|
# Letzte Aktualisierung: 2026-05-14
|
||||||
|
|
||||||
## Was ist Ban Yaro?
|
## Was ist Ban Yaro?
|
||||||
|
|
||||||
|
|
@ -10,7 +10,11 @@ Kein App Store, kein US-Konzern, DSGVO-konform, selbst gehostet in Deutschland.
|
||||||
|
|
||||||
Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa."
|
Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa."
|
||||||
|
|
||||||
Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartphones installierbar
|
Ban Yaro richtet sich an zwei Zielgruppen, die nahtlos verzahnt sind:
|
||||||
|
- **Hundebesitzer**: vollständige Alltags-App (Tagebuch, Gesundheit, Training, Gassi-Community)
|
||||||
|
- **Züchter**: professionelles Zucht-Management direkt in derselben App (Warteliste, Läufigkeit, Wurf, Stammbaum)
|
||||||
|
|
||||||
|
Ban Yaro ist im Freemium-Modell nutzbar. Die App ist auf allen Smartphones installierbar
|
||||||
(iOS und Android) direkt über den Browser — ohne App Store.
|
(iOS und Android) direkt über den Browser — ohne App Store.
|
||||||
|
|
||||||
## Der Name „Ban Yaro"
|
## Der Name „Ban Yaro"
|
||||||
|
|
@ -33,12 +37,12 @@ gegründet, mit eigenem Schutzrecht auf den Namen.
|
||||||
- Keine Werbung, keine Datenweitergabe an Dritte, kein Tracking (Umami, cookieless)
|
- Keine Werbung, keine Datenweitergabe an Dritte, kein Tracking (Umami, cookieless)
|
||||||
- Kontakt: hallo@banyaro.app
|
- Kontakt: hallo@banyaro.app
|
||||||
- Keine App-Store-Abhängigkeit: Als PWA direkt installierbar, keine Gatekeeper
|
- Keine App-Store-Abhängigkeit: Als PWA direkt installierbar, keine Gatekeeper
|
||||||
- Aktuelle Version: v1.5.1 (Mai 2026), SW by-v885
|
- Aktuelle Version: v1.5.1 (Mai 2026), SW by-v918
|
||||||
|
|
||||||
## Zielgruppe
|
## Zielgruppe
|
||||||
|
|
||||||
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
|
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
|
||||||
- Verantwortungsvolle Hundezüchter (VDH und andere Verbände)
|
- Verantwortungsvolle Hundezüchter (VDH und andere Verbände) — dedizierte Landing Page: https://banyaro.app/zuechter
|
||||||
- Welpen-Interessenten und Käufer
|
- Welpen-Interessenten und Käufer
|
||||||
- Hundeschulen und Hundetrainer
|
- Hundeschulen und Hundetrainer
|
||||||
- Tierärzte und Praxen
|
- Tierärzte und Praxen
|
||||||
|
|
@ -182,21 +186,10 @@ Die Startseite für eingeloggte Nutzer zeigt:
|
||||||
- KI lokal: LM Studio (Gemma-4-31B)
|
- KI lokal: LM Studio (Gemma-4-31B)
|
||||||
- KI Cloud: Claude API (claude-sonnet-4-6, Anthropic)
|
- KI Cloud: Claude API (claude-sonnet-4-6, Anthropic)
|
||||||
|
|
||||||
## Monetarisierung
|
|
||||||
|
|
||||||
**Kostenlos:**
|
|
||||||
- Alle Basis-Features inkl. Züchter-Antrag, Wurfverwaltung, Stammbaum, Tierschutz-Check
|
|
||||||
|
|
||||||
**Züchter-Provision** (geplant): Wurfbörse bleibt für Käufer kostenlos
|
|
||||||
|
|
||||||
**Ban Yaro Plus** (ca. 4,99 €/Monat, in Entwicklung):
|
|
||||||
- KI-Trainingsplan, erweiterte Statistiken
|
|
||||||
|
|
||||||
**Hundesitting**: 8% Provision
|
|
||||||
|
|
||||||
## Öffentliche Seiten (ohne Login)
|
## Öffentliche Seiten (ohne Login)
|
||||||
|
|
||||||
- https://banyaro.app — Landing Page
|
- https://banyaro.app — Landing Page (Hundebesitzer + Züchter)
|
||||||
|
- https://banyaro.app/zuechter — Dedizierte Landing Page für Züchter
|
||||||
- https://banyaro.app/info — Landing Page (Alias)
|
- https://banyaro.app/info — Landing Page (Alias)
|
||||||
- https://banyaro.app/wiki/rassen — Alle Hunderassen
|
- https://banyaro.app/wiki/rassen — Alle Hunderassen
|
||||||
- https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail
|
- https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail
|
||||||
|
|
@ -250,6 +243,39 @@ Die Startseite für eingeloggte Nutzer zeigt:
|
||||||
- **Auth-geschützte Medien**: Tagebuch-, Gesundheits- und Gassi-Fotos sind nur für eingeloggte Nutzer abrufbar — kein öffentlicher Zugriff über URL möglich.
|
- **Auth-geschützte Medien**: Tagebuch-, Gesundheits- und Gassi-Fotos sind nur für eingeloggte Nutzer abrufbar — kein öffentlicher Zugriff über URL möglich.
|
||||||
- **Datenschutzerklärung v2**: Vollständige Transparenz über KI-Datenübertragungen (Gesundheitsdaten im Cloud-Prompt, Fotos bei Rassenerkennung), OpenWeatherMap und Nominatim ergänzt, Datenexport konkret beschrieben.
|
- **Datenschutzerklärung v2**: Vollständige Transparenz über KI-Datenübertragungen (Gesundheitsdaten im Cloud-Prompt, Fotos bei Rassenerkennung), OpenWeatherMap und Nominatim ergänzt, Datenexport konkret beschrieben.
|
||||||
|
|
||||||
|
## Features ab v1.5.1 — Züchter-Plattform Vollausbau (Mai 2026)
|
||||||
|
|
||||||
|
- **Warteliste**: Interessenten mit Präferenzen (Geschlecht, Farbe, Verwendungszweck) pro Zuchthündin verwalten — mit Status (Interessent / Reserviert / Abgesagt) und Kontaktdaten.
|
||||||
|
- **Läufigkeit & Trächtigkeit**: Vollständiger Zykluskalender mit Progesterontests (Datum, ng/mL, Labormethode), Deckdaten (Rüde, Methode, Datum) und automatischer Meilensteinberechnung (Geburt, Absetzen, 8-Wochen-Abgabe).
|
||||||
|
- **Wurf-Buchstabe und -Name**: Jeder Wurf hat einen Rang-Buchstaben (A-Wurf, B-Wurf…) und optional einen freien Namen (z.B. "Vatertags-Wurf").
|
||||||
|
- **Privater Züchter-Bereich**: Wurfverwaltung und Zuchtkartei zeigen Züchter-Logo und Zwingername im Header — professionelle, vertrauliche Arbeitsatmosphäre statt generischer App-Ansicht.
|
||||||
|
- **Züchter-Profilfotos**: Galerie direkt im Züchter-Profil — Fotos hochladen und Reihenfolge verwalten, öffentlich sichtbar auf der Profil-Visitenkarte.
|
||||||
|
- **Züchter-Profil als Visitenkarte**: Hero-Bereich mit Hintergrund, Hunde-Cards mit HD/ED-Ergebnis-Badges, Gesundheitsstatistik, Fotogalerie — öffentlich abrufbar unter banyaro.app/breeder/{zwingername}.
|
||||||
|
- **Dedizierte Züchter-Landing-Page**: https://banyaro.app/zuechter mit Erklärung aller Züchter-Features und Pricing.
|
||||||
|
- **Züchter-Kacheln in HUND-Welt**: Läufigkeit, Wurfverwaltung und Zuchtkartei sind als eigene Kacheln in der HUND-Navigation eingebunden — erkennbar am Z-Badge für Züchter-Features.
|
||||||
|
|
||||||
|
## Monetarisierung
|
||||||
|
|
||||||
|
**Kostenlos (dauerhaft):**
|
||||||
|
- Alle Basis-Features für Hundebesitzer: Tagebuch, Gesundheit, Gassi, Community, Forum, Wissen
|
||||||
|
- Züchter-Antrag, Wurfbörse, Stammbaum-Ansicht, Tierschutz-Check, Symptom-Checker
|
||||||
|
- 1 Hund
|
||||||
|
|
||||||
|
**Ban Yaro Pro — 29 €/Jahr:**
|
||||||
|
- Mehrere Hunde
|
||||||
|
- Ernährungsbereich (KI-Berater, BARF-Guide)
|
||||||
|
- Erweiterte Karten-Layer
|
||||||
|
- Alle künftigen Pro-Features
|
||||||
|
|
||||||
|
**Züchter-Abo — 49 €/Jahr** (Gründer-Preis: **39 €/Jahr** für die ersten 20 Züchter):
|
||||||
|
- Gesamte Züchter-Plattform: Wurfverwaltung, Zuchtkartei, Stammbaum, IK-Rechner
|
||||||
|
- Warteliste, Läufigkeit & Trächtigkeit, Kaufvertrag-Generator
|
||||||
|
- KI-Züchter-Assistenz (Wurfankündigungen, Paarungsanalyse, Jahresbericht)
|
||||||
|
- Datenexport (HTML + ODS)
|
||||||
|
- Verifiziertes Züchter-Profil mit öffentlicher Seite
|
||||||
|
|
||||||
|
**Hundesitting**: 8% Provision (im Vergleich: Rover/Pawshake 20%)
|
||||||
|
|
||||||
## Domains
|
## Domains
|
||||||
|
|
||||||
- https://banyaro.app (primäre Domain)
|
- https://banyaro.app (primäre Domain)
|
||||||
|
|
|
||||||
46
backend/static/sitemap.xml
Normal file
46
backend/static/sitemap.xml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/</loc>
|
||||||
|
<lastmod>2026-05-14</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/zuechter</loc>
|
||||||
|
<lastmod>2026-05-14</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/wiki/rassen</loc>
|
||||||
|
<lastmod>2026-05-14</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/wurfboerse</loc>
|
||||||
|
<lastmod>2026-05-14</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/knigge</loc>
|
||||||
|
<lastmod>2026-05-01</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://banyaro.app/presse</loc>
|
||||||
|
<lastmod>2026-05-14</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
</urlset>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v918';
|
const CACHE_VERSION = 'by-v939';
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,37 @@
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "SoftwareApplication",
|
"@type": "SoftwareApplication",
|
||||||
"name": "Ban Yaro — Züchter-Tool",
|
"name": "Ban Yaro — Züchter-Tool",
|
||||||
"description": "Digitales Zucht-Management für seriöse Hundezüchter: Stammbaum, Inzuchtkoeffizient nach Wright, Gesundheitstests, Gentests, Wurfverwaltung, Kaufvertrag-Generator, KI-Assistent.",
|
"description": "Professionelles Zucht-Management für seriöse Hundezüchter direkt in der Ban Yaro App: Warteliste, Läufigkeit und Trächtigkeit, Wurfverwaltung, Stammbaum, Inzuchtkoeffizient, Gesundheitstests, Gentests, KI-Assistent. Ab 49 €/Jahr, Gründer-Preis 39 €/Jahr.",
|
||||||
"url": "https://banyaro.app/zuechter",
|
"url": "https://banyaro.app/zuechter",
|
||||||
"applicationCategory": "BusinessApplication",
|
"applicationCategory": "BusinessApplication",
|
||||||
"operatingSystem": "iOS, Android, Web",
|
"operatingSystem": "iOS, Android, Web",
|
||||||
"inLanguage": "de",
|
"inLanguage": "de",
|
||||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" },
|
"offers": [
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"name": "Züchter-Abo",
|
||||||
|
"price": "49",
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"description": "Vollständige Züchter-Plattform"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"name": "Gründer-Preis (erste 20 Züchter)",
|
||||||
|
"price": "39",
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"availability": "https://schema.org/LimitedAvailability"
|
||||||
|
}
|
||||||
|
],
|
||||||
"audience": { "@type": "Audience", "audienceType": "Hundezüchter, VDH-Züchter, Rassehundzüchter" },
|
"audience": { "@type": "Audience", "audienceType": "Hundezüchter, VDH-Züchter, Rassehundzüchter" },
|
||||||
|
"softwareVersion": "1.5.1",
|
||||||
|
"dateModified": "2026-05-14",
|
||||||
"featureList": [
|
"featureList": [
|
||||||
|
"Warteliste: Interessenten mit Präferenzen pro Zuchthündin verwalten",
|
||||||
|
"Läufigkeit und Trächtigkeit: Zykluskalender, Progesterontests, Deckdaten, automatische Meilensteine",
|
||||||
|
"Wurf-Buchstabe und Wurf-Name für jeden Wurf",
|
||||||
|
"Privater Bereich mit Züchter-Logo und Zwingername im Header",
|
||||||
|
"Züchter-Profilfotos und öffentliche Visitenkarte",
|
||||||
"Stammbaum bis 4 Generationen grafisch",
|
"Stammbaum bis 4 Generationen grafisch",
|
||||||
"Inzuchtkoeffizient nach Wright mit Ampel-Bewertung",
|
"Inzuchtkoeffizient nach Wright mit Ampel-Bewertung",
|
||||||
"Probeverpaarung mit IK-Simulation",
|
"Probeverpaarung mit IK-Simulation",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/banyaro.db
|
- DB_PATH=/data/banyaro.db
|
||||||
- MEDIA_DIR=/data/media
|
- MEDIA_DIR=/data/media
|
||||||
|
- APP_URL=https://staging.banyaro.app
|
||||||
- STAGING=true
|
- STAGING=true
|
||||||
- KI_MODE=cloud
|
- KI_MODE=cloud
|
||||||
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue