Release v1.1.0
This commit is contained in:
commit
61be87f29e
19 changed files with 864 additions and 87 deletions
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
|||
user_id = int(payload["sub"])
|
||||
with db() as conn:
|
||||
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,)
|
||||
).fetchone()
|
||||
|
||||
|
|
|
|||
|
|
@ -559,6 +559,10 @@ def _migrate(conn_factory):
|
|||
("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("users", "ki_zucht_beschreibung", "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:
|
||||
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);
|
||||
""")
|
||||
|
||||
# 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
|
||||
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
|
||||
if 'js_exercise_id' not in existing_te:
|
||||
|
|
|
|||
|
|
@ -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.breeder_export import router as breeder_export_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(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(breeder_export_router, prefix="/api", tags=["Export"])
|
||||
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(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||
|
|
@ -691,7 +693,26 @@ async def favicon():
|
|||
|
||||
@app.get("/manifest.json")
|
||||
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")
|
||||
async def service_worker():
|
||||
|
|
@ -1382,6 +1403,16 @@ async def knigge_page():
|
|||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||
@app.get("/{full_path:path}")
|
||||
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(
|
||||
f"{STATIC_DIR}/index.html",
|
||||
headers={"Cache-Control": "no-cache"}
|
||||
|
|
|
|||
|
|
@ -78,13 +78,42 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
new_user_id = user["id"]
|
||||
|
||||
if data.ref_code:
|
||||
referrer = conn.execute(
|
||||
"SELECT id FROM users WHERE referral_code=? AND id != ?",
|
||||
(data.ref_code.strip().upper(), new_user_id)
|
||||
code_upper = data.ref_code.strip().upper()
|
||||
# Zuerst prüfen ob es ein Partner-Code ist
|
||||
partner = conn.execute(
|
||||
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
|
||||
(code_upper,)
|
||||
).fetchone()
|
||||
if referrer:
|
||||
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
||||
(referrer['id'], new_user_id))
|
||||
if partner:
|
||||
# Nur einlösen wenn max_uses nicht erreicht
|
||||
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"])
|
||||
_set_cookie(response, token)
|
||||
|
|
@ -120,6 +149,24 @@ async def logout(response: Response):
|
|||
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")
|
||||
async def get_referral_info(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
|
|
@ -133,11 +180,14 @@ async def get_referral_info(user=Depends(get_current_user)):
|
|||
if not code:
|
||||
code = _gen_referral_code()
|
||||
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 {
|
||||
"code": code,
|
||||
"count": row["count"] if row else 0,
|
||||
"link": f"{base}/?ref={code}",
|
||||
"code": code,
|
||||
"count": count,
|
||||
"link": f"{base}/?ref={code}",
|
||||
"discount_pct": _referral_tier(count),
|
||||
"next_tier": _referral_next(count),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ async def list_threads(
|
|||
t.antworten, t.likes, t.views,
|
||||
t.is_pinned, t.is_locked, t.foto_urls,
|
||||
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
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
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)
|
||||
)
|
||||
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
|
||||
LEFT JOIN users u ON u.id = t.user_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
|
||||
with db() as conn:
|
||||
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
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
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(
|
||||
"""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
|
||||
LEFT JOIN users u ON u.id = p.user_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]
|
||||
)
|
||||
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
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
WHERE t.id = ?""",
|
||||
|
|
@ -328,7 +328,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
|||
(thread_id,)
|
||||
)
|
||||
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
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = ?""",
|
||||
|
|
|
|||
192
backend/routes/partner.py
Normal file
192
backend/routes/partner.py
Normal 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
|
||||
|
|
@ -8,25 +8,28 @@
|
|||
1. APP SHELL
|
||||
------------------------------------------------------------ */
|
||||
#app {
|
||||
display: flex;
|
||||
display: flex;
|
||||
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 */
|
||||
#page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
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) {
|
||||
#app {
|
||||
flex-direction: row;
|
||||
}
|
||||
#page-content {
|
||||
min-height: unset;
|
||||
padding-bottom: 0;
|
||||
padding-left: var(--nav-sidebar-width);
|
||||
}
|
||||
|
|
@ -168,6 +171,10 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
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));
|
||||
padding-top: var(--space-1);
|
||||
padding-bottom: calc(var(--safe-bottom) + var(--space-1));
|
||||
|
|
|
|||
BIN
backend/static/icons/icon-180-staging.png
Normal file
BIN
backend/static/icons/icon-180-staging.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
backend/static/icons/icon-192-staging.png
Normal file
BIN
backend/static/icons/icon-192-staging.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
backend/static/icons/icon-512-staging.png
Normal file
BIN
backend/static/icons/icon-512-staging.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
|
|
@ -88,21 +88,12 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=382">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=382">
|
||||
<link rel="stylesheet" href="/css/components.css?v=382">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=500">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=500">
|
||||
<link rel="stylesheet" href="/css/components.css?v=500">
|
||||
</head>
|
||||
<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 -->
|
||||
<div id="offline-banner" aria-live="polite"
|
||||
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);
|
||||
border-top:1px solid var(--c-border,#e5e7eb);
|
||||
font-size:var(--text-xs);color:var(--c-text-muted);
|
||||
display:flex;gap:var(--space-3);padding-bottom:var(--space-2)">
|
||||
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
|
||||
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
|
||||
display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-2)">
|
||||
<div style="display:flex;gap:var(--space-3)">
|
||||
<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>
|
||||
<!-- bot-trap: kein echter Nutzer klickt hier -->
|
||||
<a href="/api/wiki/trap" aria-hidden="true" tabindex="-1"
|
||||
|
|
@ -428,6 +428,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-gruender">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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 IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ const App = (() => {
|
|||
wurfboerse: { title: 'Wurfbörse', module: null },
|
||||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||
gruender: { title: '100 Gründer', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -863,10 +864,18 @@ const App = (() => {
|
|||
|
||||
// App starten
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (IS_STAGING) {
|
||||
const b = document.getElementById('staging-banner');
|
||||
if (b) b.style.display = 'flex';
|
||||
document.title = '⚗️ ' + document.title;
|
||||
}
|
||||
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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ window.Page_admin = (() => {
|
|||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
{ id: 'system', label: 'System', icon: 'gear' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||
{ id: 'partner', label: 'Partner & Codes', icon: 'handshake' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||
];
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ window.Page_admin = (() => {
|
|||
case 'analytics': await _renderAnalytics(el); break;
|
||||
case 'system': await _renderSystem(el); break;
|
||||
case 'jobs': await _renderJobs(el); break;
|
||||
case 'partner': await _renderPartner(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1789,6 +1791,210 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// 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) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ window.Page_forum = (() => {
|
|||
<div class="forum-thread-author-row">
|
||||
<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>
|
||||
${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">
|
||||
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
|
||||
${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-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="forum-post-body">
|
||||
|
|
|
|||
154
backend/static/js/pages/gruender.js
Normal file
154
backend/static/js/pages/gruender.js
Normal 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -124,14 +124,22 @@ window.Page_settings = (() => {
|
|||
<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>
|
||||
${u.is_premium
|
||||
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
|
||||
</span>`
|
||||
: `<span class="badge" style="margin-top:var(--space-1);
|
||||
color:var(--c-text-secondary)">
|
||||
Kostenlos
|
||||
</span>`}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
|
||||
${u.is_premium
|
||||
? `<span class="badge badge-primary">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
|
||||
</span>`
|
||||
: `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
|
||||
${u.is_founder
|
||||
? `<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>
|
||||
|
|
@ -299,9 +307,9 @@ window.Page_settings = (() => {
|
|||
<!-- App empfehlen -->
|
||||
<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="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)">
|
||||
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 id="referral-body" style="padding:var(--space-4)">Lade…</div>
|
||||
|
|
@ -1092,50 +1100,88 @@ window.Page_settings = (() => {
|
|||
if (!el) return;
|
||||
try {
|
||||
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 = `
|
||||
<!-- 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="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);
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- QR-Code -->
|
||||
<div style="display:flex;justify-content:center;margin-bottom:var(--space-4)">
|
||||
<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>
|
||||
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
|
||||
<div style="position:relative;width:140px;height:140px">
|
||||
<div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
|
||||
<img src="/icons/icon-180.png" alt=""
|
||||
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 style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert
|
||||
</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>` : ''}
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
|
||||
Der Rabatt gilt für dich — sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
|
||||
</p>
|
||||
`;
|
||||
|
||||
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) {
|
||||
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 {
|
||||
await navigator.clipboard.writeText(r.link);
|
||||
UI.toast.success('Link kopiert!');
|
||||
}
|
||||
});
|
||||
// QR-Code rendern (Bibliothek lazy laden)
|
||||
|
||||
await App.loadScript('/js/qrcode.min.js');
|
||||
new QRCode(document.getElementById('ref-qr'), {
|
||||
text: r.link,
|
||||
width: 160,
|
||||
height: 160,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
text: r.link, width: 140, height: 140,
|
||||
colorDark: '#000000', colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
});
|
||||
} catch { el.innerHTML = ''; }
|
||||
|
|
@ -1283,6 +1329,16 @@ window.Page_settings = (() => {
|
|||
</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)">
|
||||
Konto erstellen
|
||||
</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 => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
|
|
@ -1390,8 +1484,10 @@ window.Page_settings = (() => {
|
|||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined);
|
||||
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || 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);
|
||||
if (refCode) sessionStorage.removeItem('by_ref_code');
|
||||
|
||||
|
|
@ -1401,7 +1497,10 @@ window.Page_settings = (() => {
|
|||
_appState.activeDog = null;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "/",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"name": "Ban Yaro — Die Hunde-Plattform",
|
||||
"short_name": "Ban Yaro",
|
||||
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.",
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v513';
|
||||
const CACHE_VERSION = 'by-v526';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||
const STATIC_ASSETS = [
|
||||
'/css/design-system.css?v=382',
|
||||
'/css/layout.css?v=382',
|
||||
'/css/components.css?v=382',
|
||||
'/css/design-system.css?v=500',
|
||||
'/css/layout.css?v=500',
|
||||
'/css/components.css?v=500',
|
||||
'/icons/phosphor.svg',
|
||||
'/js/api.js',
|
||||
'/js/ui.js',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue