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
+
+
+
+
+
+
+
+
+ | # |
+ Name |
+ E-Mail |
+ Eingeladen |
+
+ ${topRows || '| Noch keine Empfehlungen |
'}
+
+
+
+
+
+
+
+
+
+ | User |
+ Geworben von |
+ Datum |
+
+ ${recentRows || '| 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('')}