Release v1.1.0

This commit is contained in:
rene 2026-04-30 07:34:06 +02:00
commit 61be87f29e
19 changed files with 864 additions and 87 deletions

View file

@ -21,4 +21,4 @@ RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status FROM users WHERE id=?", "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner FROM users WHERE id=?",
(user_id,) (user_id,)
).fetchone() ).fetchone()

View file

@ -559,6 +559,10 @@ def _migrate(conn_factory):
("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"),
("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"),
("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"),
# Partner-Code + Gründer-Lizenz
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"),
] ]
with conn_factory() as conn: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
@ -1485,6 +1489,25 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC); CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC);
""") """)
# Partner-Codes (Influencer-Kooperationen + Gründer-Lizenz)
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS partner_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE COLLATE NOCASE,
label TEXT NOT NULL,
grants_founder INTEGER NOT NULL DEFAULT 1,
max_uses INTEGER DEFAULT NULL,
uses INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_partner_codes_code ON partner_codes(code);
""")
logger.info("Migration: partner_codes Tabelle bereit.")
except Exception as e:
logger.warning(f"Migration partner_codes: {e}")
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te: if 'js_exercise_id' not in existing_te:

View file

@ -162,6 +162,7 @@ from routes.breeder_photos import router as breeder_photos_router
from routes.zucht_hunde import router as zucht_hunde_router from routes.zucht_hunde import router as zucht_hunde_router
from routes.breeder_export import router as breeder_export_router from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -193,6 +194,7 @@ app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"]) app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"]) app.include_router(import_router, prefix="/api/import", tags=["Import"])
@ -691,7 +693,26 @@ async def favicon():
@app.get("/manifest.json") @app.get("/manifest.json")
async def manifest(): async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json") import json as _json
IS_STAGING = os.getenv("STAGING", "false").lower() == "true"
with open(f"{STATIC_DIR}/manifest.json", encoding="utf-8") as f:
data = _json.load(f)
if IS_STAGING:
data["name"] = "Ban Yaro Staging"
data["short_name"] = "BY ⚗️"
data["theme_color"] = "#7c3aed"
data["background_color"] = "#2d1b69"
data["id"] = "/staging"
# Icons mit Staging-Variante überschreiben falls vorhanden
staging_icon = "/icons/icon-192-staging.png"
import os as _os
if _os.path.exists(f"{STATIC_DIR}/icons/icon-192-staging.png"):
data["icons"] = [
{"src": "/icons/icon-192-staging.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
{"src": "/icons/icon-512-staging.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
]
from fastapi.responses import JSONResponse
return JSONResponse(content=data, media_type="application/manifest+json")
@app.get("/sw.js") @app.get("/sw.js")
async def service_worker(): async def service_worker():
@ -1382,6 +1403,16 @@ async def knigge_page():
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str): async def spa_fallback(full_path: str):
IS_STAGING = os.getenv("STAGING", "false").lower() == "true"
if IS_STAGING:
from fastapi.responses import HTMLResponse
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as f:
html = f.read()
html = html.replace(
'<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">',
'<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180-staging.png">',
)
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
return FileResponse( return FileResponse(
f"{STATIC_DIR}/index.html", f"{STATIC_DIR}/index.html",
headers={"Cache-Control": "no-cache"} headers={"Cache-Control": "no-cache"}

View file

@ -78,13 +78,42 @@ async def register(data: RegisterRequest, response: Response, request: Request):
new_user_id = user["id"] new_user_id = user["id"]
if data.ref_code: if data.ref_code:
referrer = conn.execute( code_upper = data.ref_code.strip().upper()
"SELECT id FROM users WHERE referral_code=? AND id != ?", # Zuerst prüfen ob es ein Partner-Code ist
(data.ref_code.strip().upper(), new_user_id) partner = conn.execute(
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
(code_upper,)
).fetchone() ).fetchone()
if referrer: if partner:
conn.execute("UPDATE users SET referred_by=? WHERE id=?", # Nur einlösen wenn max_uses nicht erreicht
(referrer['id'], new_user_id)) if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
(partner["id"],)
)
updates = {"referred_by": -partner["id"]}
if partner["grants_founder"]:
total_founders = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total_founders < 100:
founder_num = total_founders + 1
updates["is_founder"] = 1
updates["founder_number"] = founder_num
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
(*updates.values(), new_user_id)
)
else:
# Fallback: Referral-Code eines anderen Users
referrer = conn.execute(
"SELECT id FROM users WHERE referral_code=? AND id != ?",
(code_upper, new_user_id)
).fetchone()
if referrer:
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
token = create_token(user["id"], user["rolle"]) token = create_token(user["id"], user["rolle"])
_set_cookie(response, token) _set_cookie(response, token)
@ -120,6 +149,24 @@ async def logout(response: Response):
return {"ok": True} return {"ok": True}
_REFERRAL_TIERS = [
(50, 50),
(20, 30),
(10, 20),
]
def _referral_tier(count: int):
for threshold, discount in _REFERRAL_TIERS:
if count >= threshold:
return discount
return 0
def _referral_next(count: int):
for threshold, discount in reversed(_REFERRAL_TIERS):
if count < threshold:
return {"count": threshold, "discount": discount}
return None # Maximalstufe erreicht
@router.get("/referral") @router.get("/referral")
async def get_referral_info(user=Depends(get_current_user)): async def get_referral_info(user=Depends(get_current_user)):
with db() as conn: with db() as conn:
@ -133,11 +180,14 @@ async def get_referral_info(user=Depends(get_current_user)):
if not code: if not code:
code = _gen_referral_code() code = _gen_referral_code()
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id'])) conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id']))
base = os.getenv("APP_URL", "https://banyaro.app") count = row["count"] if row else 0
base = os.getenv("APP_URL", "https://banyaro.app")
return { return {
"code": code, "code": code,
"count": row["count"] if row else 0, "count": count,
"link": f"{base}/?ref={code}", "link": f"{base}/?ref={code}",
"discount_pct": _referral_tier(count),
"next_tier": _referral_next(count),
} }

View file

@ -131,7 +131,7 @@ async def list_threads(
t.antworten, t.likes, t.views, t.antworten, t.likes, t.views,
t.is_pinned, t.is_locked, t.foto_urls, t.is_pinned, t.is_locked, t.foto_urls,
t.created_at, t.user_id, t.created_at, t.user_id,
u.name AS autor_name u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0 WHERE t.is_deleted = 0
@ -183,7 +183,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
data.thread_lat, data.thread_lon, data.thread_ort, ct) data.thread_lat, data.thread_lon, data.thread_ort, ct)
) )
row = conn.execute( row = conn.execute(
"""SELECT t.*, u.name AS autor_name """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""", WHERE t.id = ?""",
@ -203,7 +203,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)):
uid = user['id'] if user else None uid = user['id'] if user else None
with db() as conn: with db() as conn:
thread = conn.execute( thread = conn.execute(
"""SELECT t.*, u.name AS autor_name """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ? AND t.is_deleted = 0""", WHERE t.id = ? AND t.is_deleted = 0""",
@ -218,7 +218,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)):
) )
posts = conn.execute( posts = conn.execute(
"""SELECT p.*, u.name AS autor_name """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_posts p FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id LEFT JOIN users u ON u.id = p.user_id
WHERE p.thread_id = ? WHERE p.thread_id = ?
@ -288,7 +288,7 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre
[*updates.values(), thread_id] [*updates.values(), thread_id]
) )
row = conn.execute( row = conn.execute(
"""SELECT t.*, u.name AS autor_name """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_threads t FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""", WHERE t.id = ?""",
@ -328,7 +328,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
(thread_id,) (thread_id,)
) )
row = conn.execute( row = conn.execute(
"""SELECT p.*, u.name AS autor_name """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number
FROM forum_posts p FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = ?""", WHERE p.id = ?""",

192
backend/routes/partner.py Normal file
View file

@ -0,0 +1,192 @@
"""BAN YARO — Partner-Codes + Gründer-Lizenz"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from database import db
from auth import require_admin, get_current_user
router = APIRouter()
class PartnerCodeCreate(BaseModel):
code: str
label: str
grants_founder: int = 1
max_uses: Optional[int] = None
class GrantRequest(BaseModel):
is_founder: Optional[int] = None
is_partner: Optional[int] = None
# ------------------------------------------------------------------
# Admin: Partner-Codes verwalten
# ------------------------------------------------------------------
@router.get("/admin/partner/codes")
def list_partner_codes(user=Depends(require_admin)):
"""Alle Partner-Codes mit Stats (admin only)."""
with db() as conn:
rows = conn.execute(
"""SELECT pc.id, pc.code, pc.label, pc.grants_founder,
pc.max_uses, pc.uses, pc.created_at,
u.name AS created_by_name
FROM partner_codes pc
LEFT JOIN users u ON u.id = pc.created_by
ORDER BY pc.created_at DESC"""
).fetchall()
return [dict(r) for r in rows]
@router.post("/admin/partner/codes", status_code=201)
def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)):
"""Neuen Partner-Code erstellen (admin only)."""
code = data.code.strip().upper()
if not code:
raise HTTPException(400, "Code darf nicht leer sein.")
with db() as conn:
existing = conn.execute(
"SELECT id FROM partner_codes WHERE code=?", (code,)
).fetchone()
if existing:
raise HTTPException(400, "Dieser Code existiert bereits.")
conn.execute(
"""INSERT INTO partner_codes (code, label, grants_founder, max_uses, created_by)
VALUES (?, ?, ?, ?, ?)""",
(code, data.label.strip(), data.grants_founder, data.max_uses, user["id"])
)
row = conn.execute(
"SELECT * FROM partner_codes WHERE code=?", (code,)
).fetchone()
return dict(row)
@router.delete("/admin/partner/codes/{code_id}", status_code=204)
def delete_partner_code(code_id: int, user=Depends(require_admin)):
"""Partner-Code löschen (admin only)."""
with db() as conn:
existing = conn.execute(
"SELECT id FROM partner_codes WHERE id=?", (code_id,)
).fetchone()
if not existing:
raise HTTPException(404, "Partner-Code nicht gefunden.")
conn.execute("DELETE FROM partner_codes WHERE id=?", (code_id,))
return None
@router.post("/admin/partner/users/{user_id}/grant")
def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_admin)):
"""Founder- und/oder Partner-Status für einen User setzen (admin only)."""
updates = {}
if data.is_founder is not None:
updates["is_founder"] = data.is_founder
if data.is_partner is not None:
updates["is_partner"] = data.is_partner
if not updates:
raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.")
with db() as conn:
target = conn.execute(
"SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,)
).fetchone()
if not target:
raise HTTPException(404, "User nicht gefunden.")
# Beim manuellen Vergeben von is_founder: founder_number zuweisen wenn noch keine
if updates.get("is_founder") == 1 and not target["founder_number"]:
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total >= FOUNDER_MAX:
raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.")
updates["founder_number"] = total + 1
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
(*updates.values(), user_id)
)
row = conn.execute(
"SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?",
(user_id,)
).fetchone()
return dict(row)
@router.get("/admin/users/search")
def search_users(q: str, user=Depends(require_admin)):
"""User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse)."""
with db() as conn:
rows = conn.execute(
"""SELECT id, name, email, is_founder, is_partner, rolle
FROM users WHERE name LIKE ? COLLATE NOCASE
ORDER BY name LIMIT 10""",
(f"{q}%",)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Öffentlich: Gründer-Leaderboard + Code-Info
# ------------------------------------------------------------------
FOUNDER_MAX = 100
@router.get("/partner/founders/stats")
def founders_stats():
"""Öffentliches Gründer-Leaderboard: Slots, Partner-Ranking, Gründer-Liste."""
with db() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
# Partner-Ranking: nach uses absteigend
partners = conn.execute(
"""SELECT pc.id, pc.label, pc.uses, pc.code,
u.name AS partner_username, u.is_partner
FROM partner_codes pc
LEFT JOIN users u ON u.referred_by = -pc.id AND u.is_partner=1
WHERE pc.grants_founder=1
ORDER BY pc.uses DESC"""
).fetchall()
# Erste 100 Gründer (für Galerie)
founders = conn.execute(
"""SELECT u.name, u.founder_number,
pc.label AS via_partner
FROM users u
LEFT JOIN partner_codes pc ON u.referred_by = -pc.id
WHERE u.is_founder=1 AND u.founder_number IS NOT NULL
ORDER BY u.founder_number ASC
LIMIT 100"""
).fetchall()
return {
"total": total,
"max": FOUNDER_MAX,
"open": max(0, FOUNDER_MAX - total),
"closed": total >= FOUNDER_MAX,
"partners": [dict(p) for p in partners],
"founders": [dict(f) for f in founders],
}
@router.get("/partner/codes/{code}/info")
def partner_code_info(code: str):
"""Gibt zurück ob ein Partner-Code existiert und dessen Label (öffentlich)."""
with db() as conn:
row = conn.execute(
"""SELECT code, label, grants_founder, max_uses, uses
FROM partner_codes WHERE code=?""",
(code.strip().upper(),)
).fetchone()
if not row:
raise HTTPException(404, "Partner-Code nicht gefunden.")
r = dict(row)
if r["grants_founder"]:
with db() as conn2:
total = conn2.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
r["founder_slots_open"] = max(0, FOUNDER_MAX - total)
r["redeemable"] = (r["max_uses"] is None or r["uses"] < r["max_uses"]) and total < FOUNDER_MAX
else:
r["founder_slots_open"] = None
r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"]
return r

View file

@ -8,25 +8,28 @@
1. APP SHELL 1. APP SHELL
------------------------------------------------------------ */ ------------------------------------------------------------ */
#app { #app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100dvh; /* dvh: berücksichtigt mobile Browser-Chrome */ min-height: 100dvh;
} }
/* Content-Bereich: füllt den Raum zwischen Header und Bottom-Nav */ /* Content-Bereich: füllt den Raum zwischen Header und Bottom-Nav */
#page-content { #page-content {
flex: 1; flex: 1;
overflow-y: auto; min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
overflow-x: hidden; overflow-y: auto;
overflow-x: hidden;
padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px); padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px);
-webkit-overflow-scrolling: touch;
} }
/* Desktop: Sidebar-Layout */ /* Desktop: Sidebar-Layout — kein Bottom-Nav, natürliche Höhe */
@media (min-width: 768px) { @media (min-width: 768px) {
#app { #app {
flex-direction: row; flex-direction: row;
} }
#page-content { #page-content {
min-height: unset;
padding-bottom: 0; padding-bottom: 0;
padding-left: var(--nav-sidebar-width); padding-left: var(--nav-sidebar-width);
} }
@ -168,6 +171,10 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 700; /* über Leaflet-Panes (~400) */ z-index: 700; /* über Leaflet-Panes (~400) */
/* GPU-Layer erzwingen → iOS Safari fixed-position Stabilität */
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
min-height: calc(var(--nav-bottom-height) + var(--safe-bottom)); min-height: calc(var(--nav-bottom-height) + var(--safe-bottom));
padding-top: var(--space-1); padding-top: var(--space-1);
padding-bottom: calc(var(--safe-bottom) + var(--space-1)); padding-bottom: calc(var(--safe-bottom) + var(--space-1));

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View file

@ -88,21 +88,12 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=382"> <link rel="stylesheet" href="/css/design-system.css?v=500">
<link rel="stylesheet" href="/css/layout.css?v=382"> <link rel="stylesheet" href="/css/layout.css?v=500">
<link rel="stylesheet" href="/css/components.css?v=382"> <link rel="stylesheet" href="/css/components.css?v=500">
</head> </head>
<body> <body>
<!-- Staging-Banner (nur auf staging.banyaro.app) -->
<div id="staging-banner"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:10000;
background:#7c3aed;color:#fff;font-size:0.75rem;font-weight:700;
padding:5px 16px;align-items:center;justify-content:center;gap:8px;
letter-spacing:0.04em;text-transform:uppercase">
⚗️ Staging-Umgebung — Keine Produktionsdaten
</div>
<!-- Offline-Banner --> <!-- Offline-Banner -->
<div id="offline-banner" aria-live="polite" <div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999; style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
@ -238,9 +229,18 @@
<div style="margin-top:var(--space-4);padding-top:var(--space-3); <div style="margin-top:var(--space-4);padding-top:var(--space-3);
border-top:1px solid var(--c-border,#e5e7eb); border-top:1px solid var(--c-border,#e5e7eb);
font-size:var(--text-xs);color:var(--c-text-muted); font-size:var(--text-xs);color:var(--c-text-muted);
display:flex;gap:var(--space-3);padding-bottom:var(--space-2)"> display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-2)">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span> <div style="display:flex;gap:var(--space-3)">
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span> <span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
</div>
<div style="display:flex;justify-content:center">
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
color:var(--c-text-muted);display:inline-flex;align-items:center;gap:5px">
<svg style="width:18px;height:18px;flex-shrink:0;fill:#f59e0b;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#trophy" style="fill:#f59e0b;color:#f59e0b"></use></svg>
die 100
</span>
</div>
</div> </div>
<!-- bot-trap: kein echter Nutzer klickt hier --> <!-- bot-trap: kein echter Nutzer klickt hier -->
<a href="/api/wiki/trap" aria-hidden="true" tabindex="-1" <a href="/api/wiki/trap" aria-hidden="true" tabindex="-1"
@ -428,6 +428,10 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-gruender">
<div class="page-body page-container"></div>
</section>
</main> </main>
<!-- MOBILE BOTTOM NAVIGATION --> <!-- MOBILE BOTTOM NAVIGATION -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '490'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '503'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -69,6 +69,7 @@ const App = (() => {
wurfboerse: { title: 'Wurfbörse', module: null }, wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null }, 'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -863,10 +864,18 @@ const App = (() => {
// App starten // App starten
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
if (IS_STAGING) {
const b = document.getElementById('staging-banner');
if (b) b.style.display = 'flex';
document.title = '⚗️ ' + document.title;
}
App.init(); App.init();
if (IS_STAGING) {
document.title = '⚗️ ' + document.title;
// Nach App.init() Styles direkt setzen — sonst überschreibt init sie
const _applyStaging = () => {
const nav = document.getElementById('bottom-nav');
if (!nav) return;
nav.style.cssText += ';background:#2d1b69!important;border-top-color:#7c3aed!important;box-shadow:0 -2px 12px rgba(124,58,237,0.4)!important';
nav.querySelectorAll('.nav-item-label').forEach(el => el.style.color = 'rgba(196,181,253,0.75)');
nav.querySelectorAll('.plus-btn, .nav-item-center button').forEach(el => el.style.background = '#7c3aed');
};
_applyStaging();
setTimeout(_applyStaging, 400); // nochmal nach vollständigem Render
}
}); });

View file

@ -19,6 +19,7 @@ window.Page_admin = (() => {
{ id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' }, { id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'partner', label: 'Partner & Codes', icon: 'handshake' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
]; ];
@ -88,6 +89,7 @@ window.Page_admin = (() => {
case 'analytics': await _renderAnalytics(el); break; case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break; case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break; case 'jobs': await _renderJobs(el); break;
case 'partner': await _renderPartner(el); break;
case 'audit': await _renderAudit(el); break; case 'audit': await _renderAudit(el); break;
} }
} catch (e) { } catch (e) {
@ -1789,6 +1791,210 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// TAB: AUDIT-LOG // TAB: AUDIT-LOG
// ------------------------------------------------------------------ // ------------------------------------------------------------------
async function _renderPartner(el) {
const [codes] = await Promise.all([
API.get('/api/admin/partner/codes'),
]);
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Neuen Code anlegen -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
<form id="adm-partner-create" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Code</label>
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Max. Einlösungen <span style="color:var(--c-text-muted)">(leer = unbegrenzt)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
style="width:16px;height:16px;accent-color:var(--c-primary)">
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
Gründer-Lizenz (lebenslang kostenlos)
</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
${UI.icon('plus')} Code erstellen
</button>
</form>
</div>
<!-- Aktive Codes -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
<div id="adm-partner-codes-list">
${codes.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Partner-Codes angelegt.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${codes.map(c => `
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
<td style="padding:var(--space-2) var(--space-3)">
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">
${c.grants_founder ? '✓' : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
${UI.icon('trash')} Löschen
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
</div>
<!-- User-Status vergeben -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
<form id="adm-partner-grant" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">User-ID oder Benutzername</label>
<input class="form-control" name="user_search" id="adm-grant-search"
placeholder="Name eingeben…" autocomplete="off">
<div id="adm-grant-result" style="margin-top:var(--space-2)"></div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_founder" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Gründer-Lizenz
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_partner" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Partner-Badge (Creator)
</label>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
${UI.icon('check')} Status setzen
</button>
</form>
</div>
</div>
`;
// Code erstellen
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const code = (fd.code || '').trim().toUpperCase();
if (!code) return;
await UI.asyncButton(btn, async () => {
await API.post('/api/admin/partner/codes', {
code,
label: fd.label || code,
grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0,
max_uses: fd.max_uses ? parseInt(fd.max_uses) : null,
});
UI.toast.success(`Code "${code}" erstellt.`);
await _renderPartner(el);
});
});
// Code löschen
el.querySelectorAll('.adm-del-code').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm(`Code wirklich löschen?`)) return;
const id = btn.dataset.id;
await UI.asyncButton(btn, async () => {
await API.del(`/api/admin/partner/codes/${id}`);
UI.toast.success('Code gelöscht.');
await _renderPartner(el);
});
});
});
// User suchen für Status-Vergabe
let _grantUserId = null;
const searchInput = el.querySelector('#adm-grant-search');
const grantResult = el.querySelector('#adm-grant-result');
let _searchTimeout = null;
searchInput?.addEventListener('input', () => {
clearTimeout(_searchTimeout);
_grantUserId = null;
const q = searchInput.value.trim();
if (q.length < 2) { grantResult.innerHTML = ''; return; }
_searchTimeout = setTimeout(async () => {
try {
const users = await API.get(`/api/admin/users/search?q=${encodeURIComponent(q)}`);
if (!users.length) {
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Kein User gefunden.</p>`;
return;
}
grantResult.innerHTML = users.map(u => `
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
font-size:var(--text-sm);display:flex;justify-content:space-between">
<span><strong>${u.name}</strong></span>
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
${u.is_founder ? '⭐ Gründer ' : ''}${u.is_partner ? '🤝 Partner' : ''}
</span>
</div>
`).join('');
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
div.addEventListener('click', () => {
_grantUserId = parseInt(div.dataset.id);
searchInput.value = div.dataset.name;
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt</p>`;
});
});
} catch { grantResult.innerHTML = ''; }
}, 400);
});
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
e.preventDefault();
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
const btn = e.target.querySelector('[type="submit"]');
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
await UI.asyncButton(btn, async () => {
const result = await API.post(`/api/admin/partner/users/${_grantUserId}/grant`, {
is_founder: isFounder,
is_partner: isPartner,
});
UI.toast.success(`Status für ${result.name} gesetzt.`);
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
});
});
}
async function _renderAudit(el) { async function _renderAudit(el) {
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)"> <div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -392,6 +392,7 @@ window.Page_forum = (() => {
<div class="forum-thread-author-row"> <div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div> <div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span> <span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''}
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center"> <div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}"> <button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span> ${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
@ -589,6 +590,7 @@ window.Page_forum = (() => {
<div class="forum-post-header"> <div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div> <div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span> <span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span> <span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div> </div>
<div class="forum-post-body"> <div class="forum-post-body">

View file

@ -0,0 +1,154 @@
/* ============================================================
BAN YARO Gründer-Seite
Öffentliches Leaderboard der 100 Gründer & Partner.
============================================================ */
window.Page_gruender = (() => {
let _container = null;
async function init(container, appState) {
_container = container;
_render();
_load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-2)">🏆</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Die 100 Gründer von Ban Yaro
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:480px;margin:0 auto">
Nur 100 Menschen weltweit können sagen: <em>Ich war von Anfang an dabei.</em>
Diese Plätze werden nie wieder vergeben.
</p>
</div>
<div id="grnd-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#grnd-content');
try {
const d = await API.get('/api/partner/founders/stats');
if (!d || typeof d.total === 'undefined') throw new Error('Ungültige Antwort vom Server.');
el.innerHTML = _renderStats(d);
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-muted);text-align:center">${e.message || 'Fehler beim Laden.'}</p>`;
}
}
function _renderStats(d) {
const pct = Math.round((d.total / d.max) * 100);
const open = d.open;
return `
<!-- Fortschrittsbalken -->
<div class="by-card" style="padding:var(--space-5);margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-2)">
<span style="font-weight:700;font-size:var(--text-lg)">${d.total} / ${d.max} Gründer</span>
<span style="font-size:var(--text-sm);color:${open > 0 ? 'var(--c-success,#16a34a)' : 'var(--c-danger,#dc2626)'}">
${open > 0 ? `${open} Plätze frei` : 'Geschlossen'}
</span>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:12px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${pct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
${open > 0 ? `
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0;text-align:center">
Bist du dabei? Frag einen unserer Partner nach ihrem Einladungscode.
</p>` : `
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0;text-align:center">
Alle 100 Gründer-Plätze sind vergeben. Diese Gruppe ist für immer geschlossen.
</p>`}
</div>
<!-- Partner-Challenge Leaderboard -->
${d.partners.length > 0 ? `
<div class="by-card" style="padding:var(--space-5);margin-bottom:var(--space-5)">
<h2 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-1)">
${UI.icon('trophy')} Partner-Challenge
</h2>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)">
Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.partners.map((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`;
const barPct = d.partners[0].uses > 0 ? Math.round((p.uses / d.partners[0].uses) * 100) : 0;
return `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}">
<div style="font-size:22px;min-width:32px;text-align:center">${medal}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div>
<div style="background:var(--c-surface-3,rgba(0,0,0,.08));border-radius:var(--radius-full);
height:6px;margin-top:var(--space-1);overflow:hidden">
<div style="background:#7c3aed;width:${barPct}%;height:100%;
border-radius:var(--radius-full)"></div>
</div>
</div>
<div style="font-weight:800;font-size:var(--text-lg);color:#7c3aed;min-width:36px;text-align:right">
${p.uses}
</div>
</div>
`;
}).join('')}
</div>
</div>` : ''}
<!-- Gründer-Galerie -->
${d.founders.length > 0 ? `
<div class="by-card" style="padding:var(--space-5)">
<h2 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">
${UI.icon('users')} Die Gründer
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-2)">
${d.founders.map(f => `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:800;color:#7c3aed;min-width:28px">#${f.founder_number}</span>
<span style="font-size:var(--text-sm);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(f.name)}
</span>
</div>
`).join('')}
${Array.from({length: d.open}, (_, i) => `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:2px dashed var(--c-border);display:flex;align-items:center;gap:var(--space-2);
opacity:.4">
<span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px">
#${d.total + i + 1}
</span>
<span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
</div>
`).join('')}
</div>
</div>` : `
<div class="by-card" style="padding:var(--space-6);text-align:center">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Gründer sei der Erste!
</p>
</div>`}
`;
}
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
return { init, refresh, onDogChange };
})();

View file

@ -124,14 +124,22 @@ window.Page_settings = (() => {
<div> <div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div> <div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div> <div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium <div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
? `<span class="badge badge-primary" style="margin-top:var(--space-1)"> ${u.is_premium
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus ? `<span class="badge badge-primary">
</span>` <svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
: `<span class="badge" style="margin-top:var(--space-1); </span>`
color:var(--c-text-secondary)"> : `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
Kostenlos ${u.is_founder
</span>`} ? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#handshake"></use></svg> Partner
</span>` : ''}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -299,9 +307,9 @@ window.Page_settings = (() => {
<!-- App empfehlen --> <!-- App empfehlen -->
<div class="card" style="margin-bottom:var(--space-5)" id="referral-card"> <div class="card" style="margin-bottom:var(--space-5)" id="referral-card">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)"> <div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} App empfehlen</div> <div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} Freunde werben dauerhafter Rabatt</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Lade Freunde ein jede erfolgreiche Einladung wird in deinem Profil angezeigt. 10 Freunde 20% · 20 Freunde 30% · 50 Freunde 50% lebenslang, sobald Bezahlfunktionen aktiv sind.
</div> </div>
</div> </div>
<div id="referral-body" style="padding:var(--space-4)">Lade</div> <div id="referral-body" style="padding:var(--space-4)">Lade</div>
@ -1092,50 +1100,88 @@ window.Page_settings = (() => {
if (!el) return; if (!el) return;
try { try {
const r = await API.auth.referral(); const r = await API.auth.referral();
const TIERS = [{t:10,d:20},{t:20,d:30},{t:50,d:50}];
const currentTier = r.discount_pct;
const next = r.next_tier;
// Fortschrittsbalken-Berechnung
let barPct = 0, barLabel = '';
if (!next) {
barPct = 100;
barLabel = 'Maximaler Rabatt erreicht!';
} else {
const prevT = TIERS.find(t => t.d === currentTier)?.t || 0;
barPct = Math.round(((r.count - prevT) / (next.count - prevT)) * 100);
barLabel = `Noch ${next.count - r.count} ${next.count - r.count === 1 ? 'Person' : 'Personen'} bis ${next.discount}% Rabatt`;
}
el.innerHTML = ` el.innerHTML = `
<!-- Tier-Übersicht -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
${TIERS.map(({t, d}) => {
const reached = r.count >= t;
return `<div style="flex:1;padding:var(--space-2) var(--space-1);border-radius:var(--radius-md);
text-align:center;border:2px solid ${reached ? '#7c3aed' : 'var(--c-border)'};
background:${reached ? 'rgba(124,58,237,.08)' : 'var(--c-surface-2)'}">
<div style="font-size:var(--text-lg);font-weight:800;color:${reached ? '#7c3aed' : 'var(--c-text-muted)'}">
${d}%
</div>
<div style="font-size:10px;color:var(--c-text-muted)">ab ${t} Freunden</div>
${reached ? `<div style="font-size:10px;font-weight:700;color:#7c3aed">✓ Erreicht</div>` : ''}
</div>`;
}).join('')}
</div>
<!-- Zähler + Fortschritt -->
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-1)">
<span style="font-size:var(--text-sm);font-weight:600">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'}
</span>
${currentTier > 0 ? `<span style="font-size:var(--text-xs);font-weight:700;color:#7c3aed">${currentTier}% Rabatt aktiv</span>` : ''}
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:10px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${barPct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">${barLabel}</div>
</div>
<!-- Link + QR -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md); <div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm); padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button> <button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
</div> </div>
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
<!-- QR-Code --> <div style="position:relative;width:140px;height:140px">
<div style="display:flex;justify-content:center;margin-bottom:var(--space-4)"> <div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
<div style="position:relative;width:160px;height:160px">
<div id="ref-qr" style="width:160px;height:160px;border-radius:var(--radius-md);overflow:hidden"></div>
<img src="/icons/icon-180.png" alt="" <img src="/icons/icon-180.png" alt=""
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:36px;height:36px;border-radius:8px;border:2px solid #fff"> width:32px;height:32px;border-radius:7px;border:2px solid #fff">
</div> </div>
</div> </div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)"> Der Rabatt gilt für dich sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert </p>
</div>
${r.count > 0 ? `
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
${r.count >= 1 ? `<span class="badge badge-primary">${UI.icon('star')} Botschafter</span>` : ''}
${r.count >= 5 ? `<span class="badge badge-primary">${UI.icon('star')} Super-Botschafter</span>` : ''}
${r.count >= 10 ? `<span class="badge badge-primary">${UI.icon('star')} Top-Botschafter</span>` : ''}
</div>` : ''}
`; `;
document.getElementById('ref-share-btn')?.addEventListener('click', async () => { document.getElementById('ref-share-btn')?.addEventListener('click', async () => {
const msg = `Ich bin bei Ban Yaro — der coolsten Hunde-App! Registrier dich mit meinem Link und wir wachsen zusammen 🐾`;
if (navigator.share) { if (navigator.share) {
navigator.share({ title: 'Ban Yaro — Die Hunde-App', text: 'Schau dir Ban Yaro an!', url: r.link }).catch(() => {}); navigator.share({ title: 'Ban Yaro', text: msg, url: r.link }).catch(() => {});
} else { } else {
await navigator.clipboard.writeText(r.link); await navigator.clipboard.writeText(r.link);
UI.toast.success('Link kopiert!'); UI.toast.success('Link kopiert!');
} }
}); });
// QR-Code rendern (Bibliothek lazy laden)
await App.loadScript('/js/qrcode.min.js'); await App.loadScript('/js/qrcode.min.js');
new QRCode(document.getElementById('ref-qr'), { new QRCode(document.getElementById('ref-qr'), {
text: r.link, text: r.link, width: 140, height: 140,
width: 160, colorDark: '#000000', colorLight: '#ffffff',
height: 160,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H, correctLevel: QRCode.CorrectLevel.H,
}); });
} catch { el.innerHTML = ''; } } catch { el.innerHTML = ''; }
@ -1283,6 +1329,16 @@ window.Page_settings = (() => {
</div> </div>
</div> </div>
</div> </div>
<div class="form-group" style="margin-top:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs)">
Einladungscode <span style="color:var(--c-text-muted);font-weight:400">(optional)</span>
</label>
<input class="form-control" type="text" name="partner_code" id="reg-partner-code"
placeholder="z. B. HUNDEBLOG" autocomplete="off"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em">
<div id="reg-partner-hint" style="display:none;margin-top:var(--space-1);font-size:var(--text-xs);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm)"></div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)"> <button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen Konto erstellen
</button> </button>
@ -1375,6 +1431,44 @@ window.Page_settings = (() => {
}); });
} }
// Partner-Code live validieren
const partnerInput = document.getElementById('reg-partner-code');
const partnerHint = document.getElementById('reg-partner-hint');
let _partnerValid = false;
if (partnerInput) {
// Vorausfüllen falls via sessionStorage gesetzt
const stored = sessionStorage.getItem('by_ref_code') || '';
if (stored) partnerInput.value = stored;
let _debounce = null;
partnerInput.addEventListener('input', () => {
const code = partnerInput.value.trim().toUpperCase();
partnerInput.value = code;
clearTimeout(_debounce);
partnerHint.style.display = 'none';
_partnerValid = false;
if (code.length < 3) return;
_debounce = setTimeout(async () => {
try {
const info = await API.get(`/api/partner/codes/${encodeURIComponent(code)}/info`);
if (info.redeemable) {
partnerHint.textContent = info.grants_founder
? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!`
: `✓ Gültiger Einladungscode von "${info.label}"`;
partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
_partnerValid = true;
} else {
partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.';
partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
}
} catch {
partnerHint.textContent = 'Code nicht gefunden.';
partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)';
}
}, 500);
});
}
document.getElementById('auth-form')?.addEventListener('submit', async e => { document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
@ -1390,8 +1484,10 @@ window.Page_settings = (() => {
} }
await UI.asyncButton(btn, async () => { await UI.asyncButton(btn, async () => {
const refCode = sessionStorage.getItem('by_ref_code') || ''; const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined); const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
localStorage.setItem('by_token', result.token); localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code'); if (refCode) sessionStorage.removeItem('by_ref_code');
@ -1401,7 +1497,10 @@ window.Page_settings = (() => {
_appState.activeDog = null; _appState.activeDog = null;
document.getElementById('header-login-btn')?.remove(); document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`); const greeting = _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding(); App.showOnboarding();
}); });
}); });

View file

@ -1,6 +1,6 @@
{ {
"id": "/", "id": "/",
"version": "1.0.0", "version": "1.1.0",
"name": "Ban Yaro — Die Hunde-Plattform", "name": "Ban Yaro — Die Hunde-Plattform",
"short_name": "Ban Yaro", "short_name": "Ban Yaro",
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.", "description": "Alles rund um deinen Hund. Von Welpe bis Opa.",

View file

@ -3,16 +3,16 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v513'; const CACHE_VERSION = 'by-v526';
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
// index.html wird NICHT pre-gecacht (immer Network-First) // index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/css/design-system.css?v=382', '/css/design-system.css?v=500',
'/css/layout.css?v=382', '/css/layout.css?v=500',
'/css/components.css?v=382', '/css/components.css?v=500',
'/icons/phosphor.svg', '/icons/phosphor.svg',
'/js/api.js', '/js/api.js',
'/js/ui.js', '/js/ui.js',