diff --git a/VERSION b/VERSION index 1ab8b27..a01282d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1273 \ No newline at end of file +1270 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 8065d48..dba33c8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,9 +623,6 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), - # Gründer-Tickets: Kontingent an 50%-Rabatten, die ein Gründer an geworbene - # Freunde weitergeben kann (Liability-Cap; Admin pro Gründer anpassbar). - ("users", "founder_referral_tickets", "INTEGER NOT NULL DEFAULT 25"), # QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung ("users", "referred_qr", "TEXT"), # Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 375cf3a..715addd 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1352,19 +1352,10 @@ def _get_discount_info(conn, user_id: int) -> dict: referred_by = row["referred_by"] or 0 if referred_by > 0: referrer = conn.execute( - "SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?", (referred_by,) + "SELECT is_founder, is_founder_pending FROM users WHERE id=?", (referred_by,) ).fetchone() if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]): - # 50%-Weitergabe nur innerhalb des Ticket-Kontingents des Gründers - # (Rang unter den verifizierten Geworbenen ≤ Tickets). 50%, NICHT 100%. - rank = conn.execute( - """SELECT COUNT(*) FROM users - WHERE referred_by=? AND email_verified=1 - AND created_at <= (SELECT created_at FROM users WHERE id=?)""", - (referred_by, user_id) - ).fetchone()[0] - if rank <= (referrer["founder_referral_tickets"] or 0): - return {"discount_pct": 50, "reason": "referred_by_founder", "referral_count": row["referral_count"]} + return {"discount_pct": 100, "reason": "referred_by_founder", "referral_count": row["referral_count"]} count = row["referral_count"] for threshold, pct in [(50, 50), (20, 30), (10, 20)]: diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 3102133..7690352 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -25,7 +25,6 @@ class PartnerCodeCreate(BaseModel): class GrantRequest(BaseModel): is_founder: Optional[int] = None is_partner: Optional[int] = None - founder_tickets: Optional[int] = Field(None, ge=0, le=200) # 50%-Rabatt-Kontingent # ------------------------------------------------------------------ @@ -130,10 +129,8 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm updates["is_founder"] = data.is_founder if data.is_partner is not None: updates["is_partner"] = data.is_partner - if data.founder_tickets is not None: - updates["founder_referral_tickets"] = data.founder_tickets if not updates: - raise HTTPException(400, "Mindestens is_founder, is_partner oder founder_tickets muss angegeben werden.") + 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,) @@ -170,7 +167,7 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm (*updates.values(), user_id) ) row = conn.execute( - "SELECT id, name, email, is_founder, is_partner, founder_number, founder_referral_tickets FROM users WHERE id=?", + "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", (user_id,) ).fetchone() return dict(row) @@ -181,7 +178,7 @@ 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, founder_referral_tickets + """SELECT id, name, email, is_founder, is_partner, rolle FROM users WHERE name LIKE ? COLLATE NOCASE ORDER BY name LIMIT 10""", (f"{q}%",) diff --git a/backend/scheduler.py b/backend/scheduler.py index 8c8009c..700d047 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -326,22 +326,12 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s discount_reason = "founder" elif (disc_row["referred_by"] or 0) > 0: ref = conn.execute( - "SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?", + "SELECT is_founder, is_founder_pending FROM users WHERE id=?", (disc_row["referred_by"],) ).fetchone() if ref and (ref["is_founder"] or ref["is_founder_pending"]): - # 50%-Weitergabe nur solange der Gründer Tickets hat: dieser Freund - # bekommt sie, wenn sein Rang unter den verifizierten Geworbenen - # (nach Anmeldedatum) das Ticket-Kontingent nicht übersteigt. - rank = conn.execute( - """SELECT COUNT(*) FROM users - WHERE referred_by=? AND email_verified=1 - AND created_at <= (SELECT created_at FROM users WHERE id=?)""", - (disc_row["referred_by"], user["id"]) - ).fetchone()[0] - if rank <= (ref["founder_referral_tickets"] or 0): - discount_pct = 50 - discount_reason = "referred_by_founder" + discount_pct = 50 + discount_reason = "referred_by_founder" if not discount_reason: for thr, pct in [(50, 50), (20, 30), (10, 20)]: if referral_count >= thr: diff --git a/backend/static/index.html b/backend/static/index.html index 0688cd4..bedfe18 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -620,11 +620,11 @@ - - - - - + + + + + @@ -634,7 +634,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b6070bc..5bfdfc1 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1273'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1270'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; @@ -585,21 +585,8 @@ const App = (() => { navigate(page, false); }); - // Hash-Navigation: plain ändert nur die URL (kein data-page, - // kein App.navigate). Ohne diesen Listener bliebe die Seite stehen / zeigte den - // Default 'diary' bei gleicher URL. pushState (in navigate) feuert KEIN hashchange - // → keine Schleife; der Listener greift nur bei echten Hash-Klicks/Edits. - window.addEventListener('hashchange', () => { - const [page, query] = location.hash.replace('#', '').split('?'); - if (!page || !pages[page] || page === state.page) return; - const params = {}; - if (query) new URLSearchParams(query).forEach((v, k) => { params[k] = isNaN(v) ? v : Number(v); }); - _closeSidebar(); - navigate(page, false, params); // Hash steht schon - history.replaceState({ page }, '', location.hash); // damit Back/Forward die Seite kennt - }); - - // Hash-Navigation beim Erststart wird in init() nach _checkAuth() behandelt. + // Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier), + // damit kein doppelter _loadPage()-Aufruf entsteht. } // ---------------------------------------------------------- diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index f5ad0ee..21120e7 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2305,7 +2305,7 @@ window.Page_admin = (() => {

2. Registrierung mit Code — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als Gründer markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.

3. Partner-Status vergeben — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt Partner-Badge (blaues Badge im Profil) und Gründer-Lizenz. So ist auch er als Gründer #X sichtbar.

Max. 100 Gründer — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.

-

Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — dauerhaft auf Ban Yaro Pro. Gründer können zusätzlich ihren Geworbenen 50 % schenken (begrenzt durch ihre Gründer-Tickets, Standard 25).

+

Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — lebenslang, sobald Bezahlfunktionen aktiv sind.

@@ -2571,11 +2571,6 @@ window.Page_admin = (() => { Partner-Badge (Creator) -
- - -
@@ -2777,7 +2772,6 @@ window.Page_admin = (() => { grantResult.innerHTML = users.map(u => `
@@ -2796,7 +2790,6 @@ window.Page_admin = (() => { if (form) { form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1'; form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1'; - form.querySelector('[name="founder_tickets"]').value = div.dataset.tickets ?? 25; } grantResult.innerHTML = `

✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}

`; }); @@ -2813,14 +2806,14 @@ window.Page_admin = (() => { 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; - const ticketsRaw = e.target.querySelector('[name="founder_tickets"]').value.trim(); await UI.asyncButton(btn, async () => { - const body = { is_founder: isFounder, is_partner: isPartner }; - if (ticketsRaw !== '') body.founder_tickets = parseInt(ticketsRaw); - const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, body); + const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, { + is_founder: isFounder, + is_partner: isPartner, + }); if (!result) throw new Error('Keine Antwort vom Server.'); UI.toast.success(`Status für ${result.name} gesetzt.`); - grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'} | 🎟 ${result.founder_referral_tickets ?? 25} Tickets

`; + grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}

`; }).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.')); }); } diff --git a/backend/static/js/pages/gruender.js b/backend/static/js/pages/gruender.js index 6855717..b528c59 100644 --- a/backend/static/js/pages/gruender.js +++ b/backend/static/js/pages/gruender.js @@ -73,8 +73,6 @@ window.Page_gruender = (() => {

`}
- ${_renderSystem(open)} - ${d.partners.length > 0 ? `
@@ -147,82 +145,6 @@ window.Page_gruender = (() => { } - // ---------------------------------------------------------- - // "Wie funktioniert das?" — das Gründer-System erklären - // ---------------------------------------------------------- - function _renderSystem(open) { - const step = (n, icon, title, text) => ` -
-
${n}
-
-
- ${UI.icon(icon)} ${title} -
-
${text}
-
-
`; - - const benefit = (icon, title, text) => ` -
-
${icon}
-
-
${title}
-
${text}
-
-
`; - - return ` - -
-

- ${UI.icon('seal-check')} Was steckt dahinter? -

-

- Ban Yaro wird von Hundemenschen für Hundemenschen aufgebaut. Die ersten 100, - die über einen unserer Partner an Bord kommen, bekommen einen festen Platz in der Geschichte - der App — sichtbar, dauerhaft und nie wieder vergeben. -

- -
So wirst du Gründer
-
- ${step(1, 'ticket', 'Einladungscode eines Partners', - 'Unsere Partner (Hundeblogs, Trainer:innen, Vereine) haben persönliche Einladungscodes — als Link, QR-Code auf Sticker oder zum Eintippen. Registrierst du dich darüber, ist dein Gründer-Platz reserviert.')} - ${step(2, 'paw-print', 'Erstes Hundeprofil anlegen', - 'Sobald du deinen Hund anlegst, wird der reservierte Platz fest dir zugeschrieben — du erhältst deine feste Gründer-Nummer in der Reihenfolge der Anmeldung.')} - ${step(3, 'seal-check', 'Für immer Gründer #N', - 'Deine Nummer bleibt dir — egal was kommt. Auch wenn alle 100 Plätze vergeben sind, behältst du deinen.')} -
- -
Deine Vorteile
-
- ${benefit('🏅', 'Nummerierte Gründer-Badge', - 'Ein „Gründer #N"-Abzeichen, dauerhaft sichtbar in deinem Profil und neben jedem Forum-Beitrag.')} - ${benefit('👑', 'Lebenslang Ban Yaro Pro', - 'Alle Pro-Funktionen — für dich dauerhaft kostenlos, solange es Ban Yaro gibt.')} - ${benefit('🎟️', '25 Freunde zum halben Preis', - 'Du bekommst 25 Einladungen: Wer sich darüber registriert, erhält Ban Yaro Pro dauerhaft für die Hälfte. Dein Geschenk an deine Liebsten.')} - ${benefit('🌱', 'Teil der Geschichte', - 'Du gehörst zu den Menschen, die Ban Yaro von Anfang an getragen haben — das bleibt.')} -
- -
- ${open > 0 - ? `Noch ${open} von 100 Plätzen frei. Du kennst keinen Partner? - Hier siehst du, wer gerade einlädt.` - : `Alle 100 Plätze sind vergeben — diese Gruppe ist für immer geschlossen.`} -
-
`; - } - - return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index be5d09a..6a36d26 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -882,7 +882,7 @@ window.Page_settings = (() => {
${UI.icon('arrow-square-out')} Freunde werben — dauerhafter Rabatt
- 10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — dauerhaft auf Ban Yaro Pro. + 10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind.
Lade…
@@ -1821,7 +1821,7 @@ window.Page_settings = (() => {

- Der Rabatt gilt für dich auf Ban Yaro Pro — dauerhaft und automatisch. + Der Rabatt gilt für dich — sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.

`; diff --git a/backend/static/landing.html b/backend/static/landing.html index 02ee9f7..cfed93e 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 794d97c..7998404 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1273'; +const VER = '1270'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_founder_tickets.py b/tests/test_founder_tickets.py deleted file mode 100644 index f5f9b57..0000000 --- a/tests/test_founder_tickets.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Gründer-Tickets: 50%-Rabatt-Weitergabe ist pro Gründer auf sein Kontingent gedeckelt. - -Hintergrund: Ein Gründer kann geworbenen Freunden 50% auf Pro schenken. Ohne Cap -könnten 100 Gründer unbegrenzt viele 50%-Rabatte vergeben — unkalkulierbare Liability. -Jeder Gründer hat daher ein Ticket-Kontingent (Standard 25), das die ersten N -verifizierten Geworbenen abdeckt. -""" - -import secrets -from datetime import datetime, timedelta - - -def _make_founder(email, tickets=25): - from database import db - with db() as conn: - uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"] - conn.execute( - "UPDATE users SET is_founder=1, founder_number=99, founder_referral_tickets=? WHERE id=?", - (tickets, uid), - ) - return uid - - -def _add_referred(founder_id, n, verified=True, base_minutes=0): - """Legt n direkt in der DB an, die vom Gründer geworben wurden (mit gestaffeltem created_at).""" - from database import db - ids = [] - with db() as conn: - for i in range(n): - ts = (datetime(2026, 1, 1) + timedelta(minutes=base_minutes + i)).isoformat() - conn.execute( - """INSERT INTO users (email, name, pw_hash, referred_by, email_verified, created_at) - VALUES (?,?,?,?,?,?)""", - (f"ref-{secrets.token_hex(5)}@example.com", f"r{secrets.token_hex(3)}", - "x", founder_id, 1 if verified else 0, ts), - ) - ids.append(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) - return ids - - -def _discount(client, admin, uid): - r = client.get(f"/api/admin/users/{uid}/discount", headers=admin["headers"]) - assert r.status_code == 200, r.text - return r.json() - - -def test_referred_by_founder_is_50_not_100(client, admin, user): - """Bugfix-Absicherung: Geworbene eines Gründers bekommen 50%, nicht 100%.""" - fid = _make_founder(user["email"], tickets=25) - friend = _add_referred(fid, 1)[0] - d = _discount(client, admin, friend) - assert d["discount_pct"] == 50 - assert d["reason"] == "referred_by_founder" - - -def test_tickets_cap_the_50_percent(client, admin, user): - """Mit 2 Tickets bekommen nur die ersten 2 Geworbenen 50%, der 3. nichts.""" - fid = _make_founder(user["email"], tickets=2) - f1, f2, f3 = _add_referred(fid, 3) - assert _discount(client, admin, f1)["discount_pct"] == 50 - assert _discount(client, admin, f2)["discount_pct"] == 50 - d3 = _discount(client, admin, f3) - assert d3["discount_pct"] == 0 - assert d3["reason"] is None - - -def test_unverified_dont_consume_tickets(client, admin, user): - """Unbestätigte Geworbene verbrauchen kein Ticket — ein späterer bestätigter bekommt 50%.""" - fid = _make_founder(user["email"], tickets=1) - # 2 unbestätigte zuerst, dann 1 bestätigter - _add_referred(fid, 2, verified=False, base_minutes=0) - later = _add_referred(fid, 1, verified=True, base_minutes=10)[0] - assert _discount(client, admin, later)["discount_pct"] == 50 - - -def test_admin_grant_sets_tickets(client, admin, user): - """Admin kann das Ticket-Kontingent über den Grant-Endpoint setzen.""" - from database import db - with db() as conn: - uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] - r = client.post(f"/api/admin/partner/users/{uid}/grant", headers=admin["headers"], - json={"is_founder": 1, "founder_tickets": 50}) - assert r.status_code == 200, r.text - assert r.json()["founder_referral_tickets"] == 50 - with db() as conn: - val = conn.execute("SELECT founder_referral_tickets FROM users WHERE id=?", (uid,)).fetchone()[0] - assert val == 50