Compare commits
3 commits
5f01abc590
...
a388fb2f5f
| Author | SHA1 | Date | |
|---|---|---|---|
| a388fb2f5f | |||
| 60fb866283 | |||
| 98ec6c36c6 |
13 changed files with 244 additions and 34 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1270
|
||||
1273
|
||||
|
|
@ -623,6 +623,9 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1352,10 +1352,19 @@ 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 FROM users WHERE id=?", (referred_by,)
|
||||
"SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?", (referred_by,)
|
||||
).fetchone()
|
||||
if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]):
|
||||
return {"discount_pct": 100, "reason": "referred_by_founder", "referral_count": row["referral_count"]}
|
||||
# 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"]}
|
||||
|
||||
count = row["referral_count"]
|
||||
for threshold, pct in [(50, 50), (20, 30), (10, 20)]:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ 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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -129,8 +130,10 @@ 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 oder is_partner muss angegeben werden.")
|
||||
raise HTTPException(400, "Mindestens is_founder, is_partner oder founder_tickets muss angegeben werden.")
|
||||
with db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,)
|
||||
|
|
@ -167,7 +170,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 FROM users WHERE id=?",
|
||||
"SELECT id, name, email, is_founder, is_partner, founder_number, founder_referral_tickets FROM users WHERE id=?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
|
@ -178,7 +181,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
|
||||
"""SELECT id, name, email, is_founder, is_partner, rolle, founder_referral_tickets
|
||||
FROM users WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY name LIMIT 10""",
|
||||
(f"{q}%",)
|
||||
|
|
|
|||
|
|
@ -326,12 +326,22 @@ 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 FROM users WHERE id=?",
|
||||
"SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?",
|
||||
(disc_row["referred_by"],)
|
||||
).fetchone()
|
||||
if ref and (ref["is_founder"] or ref["is_founder_pending"]):
|
||||
discount_pct = 50
|
||||
discount_reason = "referred_by_founder"
|
||||
# 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"
|
||||
if not discount_reason:
|
||||
for thr, pct in [(50, 50), (20, 30), (10, 20)]:
|
||||
if referral_count >= thr:
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1270"></script>
|
||||
<script src="/js/boot-early.js?v=1273"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1270">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1273">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1273">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1273">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1273">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1273">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -620,11 +620,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1270"></script>
|
||||
<script src="/js/ui.js?v=1270"></script>
|
||||
<script src="/js/app.js?v=1270"></script>
|
||||
<script src="/js/worlds.js?v=1270"></script>
|
||||
<script src="/js/offline-indicator.js?v=1270"></script>
|
||||
<script src="/js/api.js?v=1273"></script>
|
||||
<script src="/js/ui.js?v=1273"></script>
|
||||
<script src="/js/app.js?v=1273"></script>
|
||||
<script src="/js/worlds.js?v=1273"></script>
|
||||
<script src="/js/offline-indicator.js?v=1273"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -634,7 +634,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1270"></script>
|
||||
<script src="/js/boot.js?v=1273"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1270'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1273'; // ← 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,8 +585,21 @@ const App = (() => {
|
|||
navigate(page, false);
|
||||
});
|
||||
|
||||
// Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
|
||||
// damit kein doppelter _loadPage()-Aufruf entsteht.
|
||||
// Hash-Navigation: plain <a href="#page"> ä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.
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2305,7 +2305,7 @@ window.Page_admin = (() => {
|
|||
<p style="margin:0"><strong>2. Registrierung mit Code</strong> — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als <em>Gründer</em> markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.</p>
|
||||
<p style="margin:0"><strong>3. Partner-Status vergeben</strong> — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt <em>Partner-Badge</em> (blaues Badge im Profil) und <em>Gründer-Lizenz</em>. So ist auch er als Gründer #X sichtbar.</p>
|
||||
<p style="margin:0"><strong>Max. 100 Gründer</strong> — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.</p>
|
||||
<p style="margin:0"><strong>Freunde werben</strong> — 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.</p>
|
||||
<p style="margin:0"><strong>Freunde werben</strong> — 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).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2571,6 +2571,11 @@ window.Page_admin = (() => {
|
|||
Partner-Badge (Creator)
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-xs">Gründer-Tickets <span class="text-muted">(50%-Rabatt-Kontingent für geworbene Freunde · Standard 25)</span></label>
|
||||
<input class="form-control" name="founder_tickets" id="adm-grant-tickets" type="number" min="0" max="200"
|
||||
placeholder="25" style="max-width:120px">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
|
||||
${UI.icon('check')} Status setzen
|
||||
</button>
|
||||
|
|
@ -2772,6 +2777,7 @@ window.Page_admin = (() => {
|
|||
grantResult.innerHTML = users.map(u => `
|
||||
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
|
||||
data-founder="${u.is_founder||0}" data-partner="${u.is_partner||0}"
|
||||
data-tickets="${u.founder_referral_tickets ?? 25}"
|
||||
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">
|
||||
|
|
@ -2790,6 +2796,7 @@ 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 = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}</p>`;
|
||||
});
|
||||
|
|
@ -2806,14 +2813,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 result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, {
|
||||
is_founder: isFounder,
|
||||
is_partner: isPartner,
|
||||
});
|
||||
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);
|
||||
if (!result) throw new Error('Keine Antwort vom Server.');
|
||||
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>`;
|
||||
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'} | 🎟 ${result.founder_referral_tickets ?? 25} Tickets</p>`;
|
||||
}).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ window.Page_gruender = (() => {
|
|||
</p>`}
|
||||
</div>
|
||||
|
||||
${_renderSystem(open)}
|
||||
|
||||
<!-- Partner-Challenge Leaderboard -->
|
||||
${d.partners.length > 0 ? `
|
||||
<div class="by-card" style="padding:var(--space-5);margin-bottom:var(--space-5)">
|
||||
|
|
@ -145,6 +147,82 @@ window.Page_gruender = (() => {
|
|||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// "Wie funktioniert das?" — das Gründer-System erklären
|
||||
// ----------------------------------------------------------
|
||||
function _renderSystem(open) {
|
||||
const step = (n, icon, title, text) => `
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<div style="width:34px;height:34px;border-radius:50%;flex-shrink:0;
|
||||
background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:var(--text-sm)">${n}</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700;font-size:var(--text-sm);display:flex;align-items:center;gap:6px">
|
||||
${UI.icon(icon)} ${title}
|
||||
</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.55;margin-top:2px">${text}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const benefit = (icon, title, text) => `
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;padding:var(--space-3);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md)">
|
||||
<div style="font-size:20px;line-height:1.2">${icon}</div>
|
||||
<div class="flex-1-min">
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${title}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5;margin-top:1px">${text}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<!-- Was steckt dahinter? -->
|
||||
<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('seal-check')} Was steckt dahinter?
|
||||
</h2>
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4);line-height:1.6">
|
||||
Ban Yaro wird von Hundemenschen für Hundemenschen aufgebaut. Die <strong>ersten 100</strong>,
|
||||
die über einen unserer Partner an Bord kommen, bekommen einen festen Platz in der Geschichte
|
||||
der App — sichtbar, dauerhaft und nie wieder vergeben.
|
||||
</p>
|
||||
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||
color:var(--c-text-muted);margin-bottom:var(--space-3)">So wirst du Gründer</div>
|
||||
<div class="flex-col-gap-3" style="margin-bottom:var(--space-5)">
|
||||
${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.')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;
|
||||
color:var(--c-text-muted);margin-bottom:var(--space-3)">Deine Vorteile</div>
|
||||
<div class="flex-col-gap-2" style="margin-bottom:var(--space-4)">
|
||||
${benefit('🏅', 'Nummerierte Gründer-Badge',
|
||||
'Ein <strong>„Gründer #N"</strong>-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.')}
|
||||
</div>
|
||||
|
||||
<div style="padding:var(--space-3);border-radius:var(--radius-md);
|
||||
background:linear-gradient(135deg,rgba(124,58,237,.08),rgba(168,85,247,.08));
|
||||
border:1px solid rgba(124,58,237,.2);font-size:var(--text-xs);
|
||||
color:var(--c-text-secondary);line-height:1.55;text-align:center">
|
||||
${open > 0
|
||||
? `Noch <strong>${open}</strong> von 100 Plätzen frei. Du kennst keinen Partner?
|
||||
<a href="#partner" class="text-primary">Hier siehst du, wer gerade einlädt.</a>`
|
||||
: `Alle 100 Plätze sind vergeben — diese Gruppe ist für immer geschlossen.`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -882,7 +882,7 @@ window.Page_settings = (() => {
|
|||
<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')} Freunde werben — dauerhafter Rabatt</div>
|
||||
<div class="text-xs-secondary">
|
||||
10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind.
|
||||
10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — dauerhaft auf Ban Yaro Pro.
|
||||
</div>
|
||||
</div>
|
||||
<div id="referral-body" class="p-4">Lade…</div>
|
||||
|
|
@ -1821,7 +1821,7 @@ window.Page_settings = (() => {
|
|||
</div>
|
||||
</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.
|
||||
Der Rabatt gilt für dich auf Ban Yaro Pro — dauerhaft und automatisch.
|
||||
</p>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1270"></script>
|
||||
<script src="/js/landing-init.js?v=1273"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1270';
|
||||
const VER = '1273';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
87
tests/test_founder_tickets.py
Normal file
87
tests/test_founder_tickets.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue