Compare commits

...

4 commits

5 changed files with 135 additions and 52 deletions

View file

@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "939" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "942" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

View file

@ -124,9 +124,12 @@ async def action_items(user=Depends(require_mod)):
users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0]
upgrades_pending = conn.execute(
"SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL"
).fetchone()[0]
try:
upgrades_pending = conn.execute(
"SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL"
).fetchone()[0]
except Exception:
upgrades_pending = 0
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
@ -1147,19 +1150,87 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
tier_label = tier_labels.get(req["tier"], req["tier"])
tier_price = {"pro": "29 €/Jahr", "breeder": "49 €/Jahr"}.get(req["tier"], "")
_features_pro = [
("users", "Mehrere Hunde", "Bis zu 10 Hunde mit getrennten Trainings-, Gesundheits- und Ernährungsdaten"),
("fork-knife", "Ernährung", "Kalorienbedarf-Rechner, BARF-Guide, vollständige Giftliste, KI-Ernährungsberater"),
("paw-print", "Gassi-Treffen", "Hundefotos & Rasse der Teilnehmer sichtbar, Fotos hochladen und teilen"),
("chat-circle-dots", "Direktnachrichten & Chat", "Schreibe direkt mit anderen Hundebesitzern"),
("handshake", "Playdate", "Spielkameraden in der Nähe finden und verabreden"),
("airplane", "Reise-Checkliste", "Editierbare Checkliste + EU-Länder-Einreiseregeln"),
("note-pencil", "Notizblock mit KI", "KI erkennt Muster in deinen Notizen und macht Vorschläge"),
("map-trifold", "Erweiterte Karten-Layer", "Regenradar, Temperatur-Layer und weitere Kartenmodi"),
]
_features_breeder = [
("check-circle", "Alle Pro-Features inklusive", "Mehrere Hunde, Ernährung, Gassi-Community, Chat, Playdate, Reise, Karten"),
("tree-structure", "Zuchtkartei", "Gesundheitstests (HD, ED, OCD, Augen, Herz, Patella, ZTP), Gentests (MDR1, PRA, DM, vWD), Titel"),
("notebook", "Wurfverwaltung", "Würfe und Welpen verwalten, Gewichtsverlauf, Fotos, Kaufvertrag automatisch ausfüllen"),
("list-bullets", "Warteliste", "Interessenten mit Präferenzen pro Zuchthündin verwalten"),
("thermometer", "Läufigkeit & Trächtigkeit", "Zykluskalender, Progesterontests, Deckdaten, automatische Meilensteinberechnung"),
("graph", "Stammbaum & IK-Rechner", "Stammbaum bis 4 Generationen, Inzuchtkoeffizient nach Wright, Probeverpaarung"),
("sparkle", "KI-Züchter-Assistent", "Wurfankündigungen schreiben, Genetik-Erklärung, Paarungsanalyse, Jahresbericht"),
("globe", "Öffentliches Züchter-Profil", "Visitenkarte unter banyaro.app/breeder/{zwingername} mit Hunden, Fotos und Gesundheitsstatistik"),
("download-simple", "Datenexport", "Vollständiger Export als HTML-Dossier und ODS-Tabelle (LibreOffice/Excel)"),
]
features = _features_breeder if req["tier"] == "breeder" else _features_pro
def _feature_row(icon, title, desc):
return f"""
<tr>
<td style="padding:10px 12px 10px 0;vertical-align:top;width:28px">
<div style="width:28px;height:28px;border-radius:8px;background:#fdf0e3;
display:flex;align-items:center;justify-content:center;font-size:14px">✓</div>
</td>
<td style="padding:10px 0;vertical-align:top">
<div style="font-weight:700;font-size:14px;color:#1a1a1a">{title}</div>
<div style="font-size:13px;color:#666;margin-top:2px;line-height:1.4">{desc}</div>
</td>
</tr>"""
feature_rows = "".join(_feature_row(i, t, d) for i, t, d in features)
try:
from mailer import send_email, email_html
import html as _html
name_esc = _html.escape(req["name"])
body_html = f"""
<p>Hallo {req['name']},</p>
<p>dein Account wurde soeben auf <strong>{tier_label}</strong> freigeschaltet.</p>
<p>Du kannst alle {tier_label}-Features ab sofort in der App nutzen.
Öffne Ban Yaro und lade die App einmal neu dann ist dein neuer Tarif aktiv.</p>
<p>Vielen Dank für dein Vertrauen!</p>
<p>Viele Grüße<br>René &amp; das Ban Yaro Team</p>"""
html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen")
plain = (f"Hallo {req['name']},\n\ndein Account wurde auf {tier_label} freigeschaltet.\n"
f"Öffne Ban Yaro und lade die App neu.\n\nViele Grüße\nRené")
await send_email(req["email"], f"Dein {tier_label}-Zugang ist aktiv", html, plain)
<p style="margin:0 0 8px;font-size:22px;font-weight:800;color:#1a1a1a">
Herzlichen Glückwunsch, {name_esc}! 🎉
</p>
<p style="margin:0 0 20px;font-size:15px;color:#555">
Dein Account wurde soeben auf <strong style="color:#C4843A">{tier_label}</strong>
freigeschaltet. Vielen Dank für dein Vertrauen in Ban Yaro!
</p>
<div style="background:#fdf6ef;border-radius:10px;padding:16px 20px;margin-bottom:24px">
<div style="font-size:12px;font-weight:700;color:#C4843A;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:4px">Dein Tarif</div>
<div style="font-size:18px;font-weight:800;color:#1a1a1a">{tier_label}
<span style="font-size:13px;font-weight:400;color:#888;margin-left:8px">{tier_price}</span>
</div>
</div>
<div style="font-size:13px;font-weight:700;color:#888;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:12px">Deine neuen Features</div>
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
{feature_rows}
</table>
<div style="background:#f0f7ff;border-left:3px solid #C4843A;border-radius:0 8px 8px 0;
padding:12px 16px;margin-bottom:8px;font-size:13px;color:#444;line-height:1.5">
<strong>So aktivierst du deine Features:</strong><br>
Öffne Ban Yaro und lade die App einmal neu (Startseite antippen herunterziehen
oder App schließen und neu öffnen). Alle Features sind dann sofort verfügbar.
</div>"""
html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro jetzt öffnen")
plain = (f"Herzlichen Glückwunsch, {req['name']}!\n\n"
f"Dein Account wurde auf {tier_label} ({tier_price}) freigeschaltet.\n\n"
f"Öffne Ban Yaro und lade die App neu — alle Features sind dann aktiv.\n\n"
f"Viele Grüße\nRené & das Ban Yaro Team")
await send_email(req["email"], f"🎉 Dein {tier_label}-Zugang ist aktiv!", html, plain)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '939'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '942'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -3519,55 +3519,65 @@ window.Page_admin = (() => {
const pending = rows.filter(r => !r.fulfilled_at);
const done = rows.filter(r => r.fulfilled_at);
const _row = (r, showBtn) => `
// Offene Anfragen als Cards (mobile-freundlich, Button immer sichtbar)
const _pendingCard = r => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(r.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
${tierBadge(r.tier)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
</div>
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04))">
${_esc(r.message)}
</div>` : ''}
</div>
</div>
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
Freischalten
</button>
</div>`;
// Erledigte als kompakte Tabellenzeilen
const _doneRow = r => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">${_esc(r.name)}<br>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${_esc(r.name)}<br>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(r.email)}</span></td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.message ? _esc(r.message) : '—'}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.created_at?.slice(0,10) || ''}</td>
<td style="padding:var(--space-2) var(--space-3)">
${showBtn
? `<button class="btn btn-sm adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;padding:4px 12px;
border-radius:var(--radius-md);cursor:pointer;font-size:var(--text-xs);font-weight:600">
Freischalten
</button>`
: `<span style="font-size:var(--text-xs);color:var(--c-success)">✓ ${r.fulfilled_at?.slice(0,10)}</span>`}
</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-success)">
${r.fulfilled_at?.slice(0,10) || ''}</td>
</tr>`;
const thead = `<thead><tr>
${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}</tr></thead>`;
el.innerHTML = `
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Offene Anfragen (${pending.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>
${pending.length
? pending.map(r => _row(r, true)).join('')
: `<tr><td colspan="5" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">
Keine offenen Anfragen
</td></tr>`}
</tbody>
</table>
<div style="margin-bottom:var(--space-4)">
<div class="by-card-section-header" style="margin-bottom:var(--space-3)">
Offene Anfragen (${pending.length})
</div>
${pending.length
? pending.map(_pendingCard).join('')
: `<div class="card" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">
Keine offenen Anfragen
</div>`}
</div>
${done.length ? `
<div class="card adm-table-card">
<div class="by-card-section-header">Erledigt (${done.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>${done.map(r => _row(r, false)).join('')}</tbody>
<thead><tr>
${['Nutzer','Tarif','Freigegeben'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}
</tr></thead>
<tbody>${done.map(_doneRow).join('')}</tbody>
</table>
</div>
</div>` : ''}`;

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v939';
const CACHE_VERSION = 'by-v942';
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
@ -238,6 +238,8 @@ self.addEventListener('fetch', event => {
// Media-Uploads + KI-Endpoints: direkt ans Netzwerk — kein Clone, kein Timeout, kein Queue
// KI-Anfragen ans lokale LLM können mehrere Minuten dauern
if (method === 'POST' && (_isMediaUpload(event.request) || url.pathname.startsWith('/api/ki/') || url.pathname.includes('/health/ki-') || url.pathname.includes('/health/symptom'))) return;
// Admin-Endpoints: kein SW-Timeout, direkt ans Netzwerk
if (url.pathname.startsWith('/api/admin/')) return;
// Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {