diff --git a/backend/main.py b/backend/main.py index 7fbe195..665f05a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -179,7 +179,10 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) if request.url.path.startswith('/media/'): - response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + if os.getenv('STAGING') == 'true': + response.headers['Cache-Control'] = 'no-cache' + else: + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' return response app.add_middleware(MediaCacheMiddleware) @@ -341,9 +344,39 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) -app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "834" # muss mit APP_VER in app.js übereinstimmen +STAGING = os.getenv("STAGING", "false").lower() == "true" +PROD_MEDIA_DIR = "/prod-media" + +_MIME_MAP = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4", + ".webm": "video/webm", ".pdf": "application/pdf", +} + +if STAGING and os.path.isdir(PROD_MEDIA_DIR): + # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) + from fastapi.responses import FileResponse as _FileResponse + + def _media_response(filepath: str): + ext = os.path.splitext(filepath)[1].lower() + mt = _MIME_MAP.get(ext, "application/octet-stream") + return _FileResponse(filepath, media_type=mt) + + @app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) + async def serve_media_staging(path: str): + staging_file = os.path.join(MEDIA_DIR, path) + if os.path.isfile(staging_file): + return _media_response(staging_file) + prod_file = os.path.join(PROD_MEDIA_DIR, path) + if os.path.isfile(prod_file): + return _media_response(prod_file) + from fastapi import HTTPException as _HE + raise _HE(404, "Media not found") +else: + app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") + +APP_VER = "855" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 5e82927..92a199d 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -583,6 +583,48 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)): return {"ok": True, "job_id": job_id} +# ------------------------------------------------------------------ +# GET /api/admin/referrals — User-wirbt-User Top 100 +# ------------------------------------------------------------------ +@router.get("/referrals") +async def referral_stats(user=Depends(require_mod)): + with db() as conn: + # Top-Werber mit Anzahl + top = conn.execute(""" + SELECT r.id, r.name, r.email, + COUNT(u.id) AS invited_count, + r.created_at AS member_since + FROM users u + JOIN users r ON r.id = u.referred_by + GROUP BY r.id + ORDER BY invited_count DESC + LIMIT 100 + """).fetchall() + + # Alle Einladungen (für Detail-Ansicht) + invites = conn.execute(""" + SELECT u.id, u.name, u.email, u.created_at, + r.id AS referrer_id, r.name AS referrer_name + FROM users u + JOIN users r ON r.id = u.referred_by + ORDER BY u.created_at DESC + LIMIT 500 + """).fetchall() + + total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + total_referred = conn.execute( + "SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL" + ).fetchone()[0] + + return { + "top_referrers": [dict(r) for r in top], + "recent_invites": [dict(r) for r in invites], + "total_users": total_users, + "total_referred": total_referred, + "viral_factor": round(total_referred / max(total_users - total_referred, 1), 2), + } + + # ------------------------------------------------------------------ # GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time) # ------------------------------------------------------------------ diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index fef8624..6beeaaf 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -188,8 +188,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): if not dog: raise HTTPException(404, "Hund nicht gefunden.") - # Zufälliges Foto aus den letzten 100 Tagebuchbildern - # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge + # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id @@ -198,12 +197,13 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() - # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill) + # Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen) if not photos: photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id WHERE d.dog_id=? AND dm.media_type='image' + AND dm.img_width IS NULL ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 7df14e4..a813532 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -172,6 +172,20 @@ async def send_request(target_id: int, user=Depends(get_current_user)): return {"ok": True} +@router.get("/pending") +async def pending_requests(user=Depends(get_current_user)): + """Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept.""" + with db() as conn: + rows = conn.execute(""" + SELECT f.id, u.name AS requester_name, u.avatar_url + FROM friendships f + JOIN users u ON u.id = f.requester_id + WHERE f.addressee_id=? AND f.status='pending' + ORDER BY f.created_at DESC + """, (user["id"],)).fetchall() + return [dict(r) for r in rows] + + @router.post("/{friendship_id}/accept") async def accept_request(friendship_id: int, user=Depends(get_current_user)): uid = user["id"] diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 6521b90..3c3c3b8 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" prompt=prompt, system=system, max_tokens=600, - requires_premium=False, + requires_premium=True, user_id=user["id"], ) return {"antwort": result} diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 6755ccc..3abbec3 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -195,6 +195,10 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): if not ORS_API_KEY: raise HTTPException(503, "ORS nicht konfiguriert") + from auth import has_pro_access + if not has_pro_access(user): + raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.") + is_privileged = ( user.get("rolle") in ("admin", "moderator") or user.get("is_moderator") or diff --git a/backend/routes/weather.py b/backend/routes/weather.py index 2167b19..0bd757a 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,6 +3,7 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ +import os import json from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module @@ -11,6 +12,34 @@ from database import db router = APIRouter() +OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "") + + +ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"} + + +@router.get('/radar-tiles') +async def radar_tile_config(user=Depends(get_current_user)): + """Regenradar-Tile-Config (RainViewer).""" + return {"provider": "rainviewer"} + + +@router.get('/layer-tiles') +async def layer_tile_config( + layer: str = "temp_new", + user=Depends(get_current_user), +): + """OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig).""" + if layer not in ALLOWED_OWM_LAYERS: + raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}") + if not OWM_API_KEY: + raise HTTPException(503, "OWM nicht konfiguriert.") + return { + "url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}", + "maxNativeZoom": 18, + "opacity": 0.6, + } + @router.get('') async def get_weather( diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 49d89ab..cb54b88 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3184,6 +3184,16 @@ html.modal-open { color: #fff; font-size: 1rem; } +#map-radar-btn.active { + background: #1d4ed8; + border-color: #1d4ed8; + color: #fff; +} +#map-temp-btn.active { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} .map-fab:disabled { opacity: 0.5; cursor: default; } .map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; } @keyframes fab-spin { to { transform: rotate(360deg); } } diff --git a/backend/static/index.html b/backend/static/index.html index b47048a..ac10748 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 0b78595..0e14dc1 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -464,6 +464,7 @@ const API = (() => { // ---------------------------------------------------------- const friends = { list() { return get('/friends/'); }, + pending() { return get('/friends/pending'); }, search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); }, activity() { return get('/friends/activity'); }, sendRequest(userId) { return post(`/friends/request/${userId}`, {}); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 40d29a9..ab9e9f0 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '834'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '855'; // ← 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 if (location.search.includes('_t=')) history.replaceState(null, '', '/'); @@ -57,7 +57,7 @@ const App = (() => { 'erste-hilfe': { title: 'Erste Hilfe', module: null }, settings: { title: 'Einstellungen', module: null }, lost: { title: 'Verlorener Hund', module: null }, - friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true }, + friends: { title: 'Freunde', module: null, requiresAuth: true }, chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true }, social: { title: 'Social Media', module: null, requiresAuth: true }, admin: { title: 'Admin', module: null, requiresAuth: true }, @@ -198,6 +198,26 @@ const App = (() => { return; } + // Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init() + const pageEl = document.getElementById(`page-${pageId}`); + if (pageEl) { + pageEl.querySelector('#pro-role-banner')?.remove(); + if (page.requiresPro && state.user) { + const t = state.user.subscription_tier || 'standard'; + const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) && + (state.user.rolle === 'admin' || state.user.rolle === 'moderator' || + state.user.is_moderator || state.user.is_social_media); + if (isRoleBased) { + const banner = document.createElement('div'); + banner.id = 'pro-role-banner'; + banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;'; + banner.innerHTML = ` + ⭐ Pro-Feature — Standard-User sehen diese Seite nicht`; + pageEl.insertBefore(banner, pageEl.firstChild); + } + } + } + if (page.module) { const hasParams = params && Object.keys(params).length > 0; if (hasParams) { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 8877953..2747256 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -25,6 +25,7 @@ window.Page_admin = (() => { { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, + { id: 'referrals', label: 'Referrals', icon: 'share-network' }, ]; // ------------------------------------------------------------------ @@ -161,6 +162,7 @@ window.Page_admin = (() => { case 'bewerbungen': await _renderBewerbungen(el); break; case 'hilfe': await _renderHilfe(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break; + case 'referrals': await _renderReferrals(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -3344,6 +3346,79 @@ window.Page_admin = (() => { } // ------------------------------------------------------------------ + // ------------------------------------------------------------------ + // TAB: REFERRALS + // ------------------------------------------------------------------ + async function _renderReferrals(el) { + el.innerHTML = `
Lade…
`; + let d; + try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `
Fehler beim Laden.
`; return; } + + const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0; + + const topRows = d.top_referrers.map((r, i) => ` + + ${i + 1} + ${_esc(r.name)} + ${_esc(r.email)} + + ${r.invited_count} + + `).join(''); + + const recentRows = d.recent_invites.slice(0, 50).map(r => ` + + ${_esc(r.name)} + ${_esc(r.referrer_name)} + ${(r.created_at || '').slice(0, 10)} + `).join(''); + + el.innerHTML = ` +
+
+
${d.total_referred}
+
Geworbene User
+
+
+
${pct}%
+
Anteil geworbener User
+
+
+
${d.viral_factor}
+
Virality Factor
+
+
+ +
+
Top Werber
+
+ + + + + + + + ${topRows || ''} +
#NameE-MailEingeladen
Noch keine Empfehlungen
+
+
+ +
+
Zuletzt geworbene User
+
+ + + + + + + ${recentRows || ''} +
UserGeworben vonDatum
Noch keine Daten
+
+
`; + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index bf6f23e..e47e571 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -203,6 +203,8 @@ window.Page_map = (() => {
+ +
@@ -285,6 +287,186 @@ window.Page_map = (() => { }); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); + document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar); + document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp); + } + + // ---------------------------------------------------------- + // REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.) + // ---------------------------------------------------------- + let _radarLayer = null; + let _radarActive = false; + let _radarTimer = null; + let _tempLayer = null; + let _tempActive = false; + let _tempMarkers = []; + let _tempDebounce = null; + + async function _toggleRadar() { + if (!App.hasPro(_appState?.user)) { + UI.toast.info('Regenradar ist ein Pro-Feature.'); + return; + } + const btn = document.getElementById('map-radar-btn'); + if (_radarActive) { + _radarActive = false; + if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; } + clearInterval(_radarTimer); + btn?.classList.remove('active'); + return; + } + _radarActive = true; + btn?.classList.add('active'); + if (_map && _map.getZoom() > 7) _map.setZoom(7); + await _loadRadar(); + _radarTimer = setInterval(_loadRadar, 5 * 60 * 1000); + } + + async function _toggleTemp() { + if (!App.hasPro(_appState?.user)) { + UI.toast.info('Temperatur-Layer ist ein Pro-Feature.'); + return; + } + const btn = document.getElementById('map-temp-btn'); + if (_tempActive) { + _tempActive = false; + if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; } + _tempMarkers.forEach(m => _map.removeLayer(m)); + _tempMarkers = []; + clearTimeout(_tempDebounce); + _map.off('moveend zoomend', _debounceTempLabels); + document.getElementById('map-temp-legend')?.remove(); + btn?.classList.remove('active'); + return; + } + _tempActive = true; + btn?.classList.add('active'); + try { + const cfg = await API.get('/weather/layer-tiles?layer=temp_new'); + _tempLayer = window.L.tileLayer(cfg.url, { + opacity: 1.0, + tileSize: 256, + zIndex: 290, + maxNativeZoom: cfg.maxNativeZoom ?? 18, + maxZoom: 18, + attribution: 'Temp © OpenWeatherMap', + }).addTo(_map); + _showTempLegend(); + _map.on('moveend zoomend', _debounceTempLabels); + await _loadTempLabels(); + } catch { + _tempActive = false; + btn?.classList.remove('active'); + UI.toast.error('Temperatur-Layer nicht verfügbar.'); + } + } + + function _debounceTempLabels() { + clearTimeout(_tempDebounce); + _tempDebounce = setTimeout(_loadTempLabels, 600); + } + + function _tempColor(t) { + if (t <= -10) return '#0033cc'; + if (t <= 0) return '#0099ff'; + if (t <= 10) return '#00cc88'; + if (t <= 15) return '#88cc00'; + if (t <= 20) return '#ffcc00'; + if (t <= 25) return '#ff8800'; + if (t <= 30) return '#ff3300'; + return '#990000'; + } + + async function _loadTempLabels() { + if (!_tempActive || !_map) return; + const bounds = _map.getBounds(); + const n = bounds.getNorth(), s = bounds.getSouth(); + const e = bounds.getEast(), w = bounds.getWest(); + + // 3×3 Raster + const rows = 3, cols = 3; + const points = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const lat = s + (n - s) * (r + 0.5) / rows; + const lon = w + (e - w) * (c + 0.5) / cols; + points.push([lat, lon]); + } + } + + const results = await Promise.all(points.map(([lat, lon]) => + fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' }) + .then(r => r.json()) + .then(d => ({ lat, lon, t: d.current?.temperature_2m })) + .catch(() => null) + )); + + // Alte Marker entfernen + _tempMarkers.forEach(m => _map.removeLayer(m)); + _tempMarkers = []; + + results.filter(Boolean).forEach(({ lat, lon, t }) => { + if (t == null) return; + const temp = Math.round(t); + const color = _tempColor(temp); + const icon = window.L.divIcon({ + className: '', + html: `
${temp}°
`, + iconSize: null, + iconAnchor: [20, 10], + }); + const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false }); + m.addTo(_map); + _tempMarkers.push(m); + }); + } + + function _showTempLegend() { + const existing = document.getElementById('map-temp-legend'); + if (existing) return; + const steps = [ + { c: '#0000cc', v: '−20°' }, { c: '#0055ff', v: '−10°' }, + { c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' }, + { c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' }, + { c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' }, + { c: '#990000', v: '35°' }, + ]; + const gradient = steps.map(s => s.c).join(','); + const labels = steps.map(s => + `${s.v}` + ).join(''); + const el = document.createElement('div'); + el.id = 'map-temp-legend'; + el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%); + z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px; + min-width:220px;pointer-events:none`; + el.innerHTML = ` +
+
${labels}
`; + document.getElementById('central-map')?.appendChild(el); + } + + async function _loadRadar() { + if (!_radarActive || !_map) return; + try { + const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' }); + const data = await resp.json(); + const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])]; + if (!frames.length) return; + const latest = frames[frames.length - 1].path; + const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`; + if (_radarLayer) _map.removeLayer(_radarLayer); + _radarLayer = window.L.tileLayer(url, { + opacity: 0.7, + tileSize: 256, + zIndex: 300, + maxNativeZoom: 7, + maxZoom: 18, + attribution: 'Radar © RainViewer', + }).addTo(_map); + } catch { /* still */ } } // ---------------------------------------------------------- @@ -1634,12 +1816,15 @@ window.Page_map = (() => { const regen = w.precip_prob != null ? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`) : ''; + const warning = w.rain_warning_time + ? ` · ⚠ ab ${w.rain_warning_time}` + : ''; let zecken = ''; if (w.zecken_warnung) { const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; zecken = ` · `; } - info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`; + info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`; info.classList.remove('map-weather-chip--hidden'); sep.classList.remove('map-weather-chip--hidden'); } catch { /* still */ } diff --git a/backend/static/js/pages/notifications.js b/backend/static/js/pages/notifications.js index 7ba6112..cedf85c 100644 --- a/backend/static/js/pages/notifications.js +++ b/backend/static/js/pages/notifications.js @@ -168,7 +168,7 @@ window.Page_notifications = (() => { ? App.callModule('poison', 'openDetail', { id: d.id }) : App.navigate('poison'); break; - case 'friend_request': App.navigate('friends'); break; + case 'friend_request': _openFriendRequestModal(); break; case 'health_reminder':App.navigate('health'); break; case 'milestone': App.navigate('diary'); break; default: @@ -379,5 +379,55 @@ window.Page_notifications = (() => { document.head.appendChild(style); } + async function _openFriendRequestModal() { + let requests; + try { requests = await API.friends.pending(); } catch { requests = []; } + + if (!requests.length) { + UI.toast.info('Keine offenen Freundschaftsanfragen.'); + return; + } + + const items = requests.map(r => ` +
+
+ ${r.avatar_url + ? `` + : ``} +
+
${UI.escape(r.requester_name)}
+ + +
`).join(''); + + UI.modal.open({ + title: 'Freundschaftsanfragen', + body: `
${items}
`, + }); + + document.querySelectorAll('[data-accept]').forEach(btn => { + btn.addEventListener('click', async () => { + await UI.asyncButton(btn, async () => { + await API.friends.accept(parseInt(btn.dataset.accept)); + UI.modal.close(); + UI.toast.success('Freundschaft angenommen!'); + }); + }); + }); + document.querySelectorAll('[data-decline]').forEach(btn => { + btn.addEventListener('click', async () => { + await UI.asyncButton(btn, async () => { + await API.friends.decline(parseInt(btn.dataset.decline)); + UI.modal.close(); + UI.toast.info('Anfrage abgelehnt.'); + }); + }); + }); + } + return { init }; })(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index deb71ae..91cd81f 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -320,7 +320,17 @@ window.Page_routes = (() => { if (actRow) actRow.style.display = 'none'; const filterPanel = document.getElementById('rk-filter-panel'); if (filterPanel) filterPanel.style.display = 'none'; - _renderSuggestTab(); + if (!App.hasPro(_appState?.user)) { + document.getElementById('rk-list')?.replaceChildren(); + const gate = document.createElement('div'); + gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)'; + gate.innerHTML = ` +
Ban Yaro Pro
+
Routenvorschläge sind ein Pro-Feature.
`; + document.getElementById('rk-list')?.appendChild(gate); + } else { + _renderSuggestTab(); + } } else { if (searchRow) searchRow.style.display = ''; if (actRow) actRow.style.display = ''; diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js index dfa7bee..35383f3 100644 --- a/backend/static/js/pages/trainingsplaene.js +++ b/backend/static/js/pages/trainingsplaene.js @@ -862,7 +862,9 @@ window.Page_trainingsplaene = (() => { } function refresh() {} - function onDogChange() {} + function onDogChange() { + _render(); + } return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index 9e554fe..d8639e6 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -553,8 +553,11 @@ window.Page_uebungen = (() => { _renderContent(); } function onDogChange() { - _statsData = null; - _badgesData = null; + _statsData = null; + _badgesData = null; + _progressCache = {}; + _exerciseStats = {}; + _render(); _loadStatsAndBadges(); _loadVirtualTrainer(); } @@ -1004,7 +1007,17 @@ window.Page_uebungen = (() => { break; } case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; - case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break; + case 'ki-trainer': + if (!App.hasPro(_appState?.user)) { + el.innerHTML = `
+ +
Ban Yaro Pro
+
Der KI-Trainer ist ein Pro-Feature.
+
`; + } else { + el.innerHTML = _renderKiTrainer(); + } + break; } _bindAccordions(); _bindStatusButtons(); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 850bdbc..88857d9 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -346,7 +346,8 @@ window.Worlds = (() => {
${worldLabels[w]}
${chips.map(c => ` -
`} + ${c.pro && _isRoleBasedPro() ? `P` : ''} @@ -969,10 +971,24 @@ window.Worlds = (() => { // ── CHIP-HELPER ────────────────────────────────────────────── - function _chip(icon, label, page, locked = false) { + function _isRoleBasedPro() { + const u = _state?.user; + if (!u) return false; + const t = u.subscription_tier || 'standard'; + if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false; + return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media; + } + + function _chip(icon, label, page, locked = false, proBadge = false) { const style = locked ? 'opacity:0.25;cursor:default;' : ''; + const badge = proBadge + ? `P` + : ''; return ` -
+
+ ${badge} @@ -1097,9 +1113,9 @@ window.Worlds = (() => { ${gassiScore ?? '—'} ${gassiScore ? `/10` : ''}
- ${w ? `
-
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
- ${w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''} + ${w ? `
+
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
+ ${w.rain_warning_time ? `
⚠ Umschwung ab ${w.rain_warning_time}
` : w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''}
` : ''}
@@ -1122,7 +1138,7 @@ window.Worlds = (() => {
Deine Bereiche
- ${features.map(f => _chip(f.icon, f.label, f.page)).join('')} + ${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}