diff --git a/backend/database.py b/backend/database.py
index 11e9efb..ed3ef16 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -675,6 +675,20 @@ def _migrate(conn_factory):
""")
logger.info("Migration: dog_shares Tabelle bereit.")
+ # Event-RSVP
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS event_rsvp (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ status TEXT DEFAULT 'going',
+ created_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(event_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_event_rsvp_event ON event_rsvp(event_id);
+ """)
+ logger.info("Migration: event_rsvp Tabelle bereit.")
+
# Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User)
_ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
if _ev_cols.get("user_id") == 1:
@@ -704,3 +718,21 @@ def _migrate(conn_factory):
ON events(external_id) WHERE external_id IS NOT NULL;
""")
logger.info("Migration: events.user_id NOT NULL Constraint entfernt.")
+
+ # Service-Angebote: Sitting + Walks Matching
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS service_offers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ type TEXT NOT NULL, -- 'sitting' oder 'walks'
+ beschreibung TEXT,
+ preis_pro_tag REAL,
+ lat REAL,
+ lon REAL,
+ radius_km INTEGER DEFAULT 10,
+ aktiv INTEGER DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv);
+ CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
+ """)
diff --git a/backend/main.py b/backend/main.py
index 0fa1980..cdaa64f 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -77,6 +77,7 @@ from routes.import_data import router as import_router
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
from routes.widget import router as widget_router
from routes.notifications import router as notifications_router
+from routes.services import router as services_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -107,6 +108,7 @@ app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
+app.include_router(services_router, prefix="/api/services", tags=["Services"])
# ------------------------------------------------------------------
diff --git a/backend/routes/events.py b/backend/routes/events.py
index 7c4f449..c066959 100644
--- a/backend/routes/events.py
+++ b/backend/routes/events.py
@@ -25,6 +25,9 @@ def _haversine(lat1, lon1, lat2, lon2):
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
+class RsvpCreate(BaseModel):
+ status: str = 'going' # 'going' | 'maybe'
+
class EventCreate(BaseModel):
titel: str
datum: str # YYYY-MM-DD
@@ -65,7 +68,8 @@ async def list_events(
q = """
SELECT e.*,
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name,
- e.quelle
+ e.quelle,
+ (SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count
FROM events e
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
WHERE e.status = 'aktiv'
@@ -101,7 +105,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
data.lat, data.lon, data.ort_name,
data.typ, data.beschreibung, data.link))
row = conn.execute(
- "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
+ "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
+ "0 AS rsvp_count "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(cur.lastrowid,)
).fetchone()
@@ -115,7 +120,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
async def get_event(event_id: int):
with db() as conn:
row = conn.execute(
- "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
+ "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
+ "(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
@@ -142,7 +148,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
row = conn.execute(
- "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
+ "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
+ "(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
@@ -161,3 +168,53 @@ async def delete_event(event_id: int, user=Depends(get_current_user)):
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))
+
+
+# ------------------------------------------------------------------
+# POST /api/events/{id}/rsvp
+# ------------------------------------------------------------------
+@router.post("/{event_id}/rsvp", status_code=201)
+async def rsvp_event(event_id: int, data: RsvpCreate, user=Depends(get_current_user)):
+ if data.status not in ('going', 'maybe'):
+ raise HTTPException(400, "Status muss 'going' oder 'maybe' sein.")
+ with db() as conn:
+ ev = conn.execute("SELECT id FROM events WHERE id = ? AND status = 'aktiv'", (event_id,)).fetchone()
+ if not ev:
+ raise HTTPException(404, "Event nicht gefunden.")
+ conn.execute(
+ "INSERT OR REPLACE INTO event_rsvp (event_id, user_id, status) VALUES (?, ?, ?)",
+ (event_id, user['id'], data.status)
+ )
+ count = conn.execute(
+ "SELECT COUNT(*) FROM event_rsvp WHERE event_id = ? AND status = 'going'", (event_id,)
+ ).fetchone()[0]
+ return {"event_id": event_id, "status": data.status, "rsvp_count": count}
+
+
+# ------------------------------------------------------------------
+# DELETE /api/events/{id}/rsvp
+# ------------------------------------------------------------------
+@router.delete("/{event_id}/rsvp", status_code=204)
+async def cancel_rsvp(event_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ conn.execute(
+ "DELETE FROM event_rsvp WHERE event_id = ? AND user_id = ?",
+ (event_id, user['id'])
+ )
+
+
+# ------------------------------------------------------------------
+# GET /api/events/{id}/rsvp
+# ------------------------------------------------------------------
+@router.get("/{event_id}/rsvp")
+async def list_rsvp(event_id: int):
+ with db() as conn:
+ rows = conn.execute(
+ "SELECT r.user_id, u.name, r.status "
+ "FROM event_rsvp r "
+ "JOIN users u ON u.id = r.user_id "
+ "WHERE r.event_id = ? "
+ "ORDER BY r.created_at ASC",
+ (event_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
diff --git a/backend/routes/forum.py b/backend/routes/forum.py
index 2823623..76aeb1c 100644
--- a/backend/routes/forum.py
+++ b/backend/routes/forum.py
@@ -1,11 +1,14 @@
"""BAN YARO — Forum (Sprint 11)"""
-import os, uuid, json
+import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
+from routes.push import send_push_to_user
+
+logger = logging.getLogger(__name__)
router = APIRouter()
@@ -295,9 +298,30 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
WHERE p.id = ?""",
(cur.lastrowid,)
).fetchone()
+ # Thread-Owner ermitteln für Push-Notification
+ owner_row = conn.execute(
+ "SELECT user_id FROM forum_threads WHERE id = ?", (thread_id,)
+ ).fetchone()
+ owner_id = owner_row['user_id'] if owner_row else None
+
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
+
+ # Push-Notification an Thread-Owner (nicht an sich selbst)
+ if owner_id and owner_id != user['id']:
+ try:
+ commenter_name = pd.get('autor_name') or 'Jemand'
+ send_push_to_user(owner_id, {
+ "type": "forum_reply",
+ "title": "Neue Antwort auf deinen Beitrag",
+ "body": f"{commenter_name} hat auf deinen Beitrag geantwortet",
+ "tag": f"forum-{thread_id}",
+ "data": {"page": "forum", "id": thread_id},
+ })
+ except Exception:
+ logger.exception("Push-Notification für Forum-Reply fehlgeschlagen (nicht kritisch)")
+
return pd
diff --git a/backend/routes/health.py b/backend/routes/health.py
index 7c26678..e758cf8 100644
--- a/backend/routes/health.py
+++ b/backend/routes/health.py
@@ -243,6 +243,22 @@ async def upload_dokument(
return {"datei_url": datei_url, "datei_typ": datei_typ}
+# ------------------------------------------------------------------
+# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/health/gewicht")
+async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ rows = conn.execute(
+ """SELECT datum, wert AS gewicht FROM health
+ WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL
+ ORDER BY datum ASC""",
+ (dog_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
# ------------------------------------------------------------------
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index eb812fe..ef6e17c 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -5044,3 +5044,93 @@ textarea.form-control {
color: var(--c-text-muted);
margin-top: 2px;
}
+
+/* health chart extras (weight chart) */
+.health-chart-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); margin-bottom: var(--space-2); }
+.health-chart-svg { width: 100%; height: auto; display: block; }
+.health-chart-labels { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: var(--space-1); }
+.health-chart-empty { font-size: var(--text-sm); color: var(--c-text-muted); text-align: center; padding: var(--space-4) 0; }
+
+/* ============================================================
+ RSVP — Event-Teilnahme
+ ============================================================ */
+.event-rsvp-bar { display:flex; gap:var(--space-2); align-items:center; margin:var(--space-3) 0 var(--space-2); flex-wrap:wrap; }
+.event-rsvp-btn { display:inline-flex; align-items:center; gap:var(--space-1); padding:6px 14px; border-radius:var(--radius); border:1.5px solid var(--c-border); background:var(--c-surface); color:var(--c-text-secondary); font-size:var(--text-sm); font-weight:500; cursor:pointer; transition:background .15s,color .15s,border-color .15s; }
+.event-rsvp-btn:hover { border-color:var(--c-primary); color:var(--c-primary); }
+.event-rsvp-btn.active { background:var(--c-primary); border-color:var(--c-primary); color:#fff; }
+.event-attendees { font-size:var(--text-sm); color:var(--c-text-secondary); cursor:pointer; display:inline-flex; align-items:center; gap:var(--space-1); }
+.event-attendees:hover { color:var(--c-primary); }
+.ev-attendees-list { display:flex; flex-wrap:wrap; gap:var(--space-1); margin-top:var(--space-2); }
+.ev-attendee-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:999px; background:var(--c-surface-2); font-size:var(--text-xs); color:var(--c-text-secondary); }
+
+/* ============================================================
+ SERVICES / MATCHING (Sitting & Walks Anbieter-Suche)
+ ============================================================ */
+.svc-matching-layout { display:flex; flex-direction:column; gap:var(--space-4); padding:var(--space-3) 0; }
+.svc-own-offer { padding:var(--space-4); }
+.svc-own-offer-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-3); }
+.svc-own-offer-title { font-weight:var(--weight-semibold); font-size:var(--text-base); }
+.svc-login-hint { font-size:var(--text-sm); color:var(--c-text-muted); }
+.svc-toggle { position:relative; display:inline-block; width:44px; height:24px; cursor:pointer; }
+.svc-toggle input { opacity:0; width:0; height:0; }
+.svc-toggle-slider { position:absolute; inset:0; background:var(--c-border); border-radius:var(--radius-full); transition:background var(--transition-fast); }
+.svc-toggle-slider::before { content:''; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform var(--transition-fast); box-shadow:0 1px 3px rgba(0,0,0,.2); }
+.svc-toggle input:checked + .svc-toggle-slider { background:var(--c-primary); }
+.svc-toggle input:checked + .svc-toggle-slider::before { transform:translateX(20px); }
+.svc-offer-form { display:flex; flex-direction:column; gap:var(--space-3); }
+.svc-offer-form--hidden { display:none; }
+.svc-hint { color:var(--c-text-secondary); font-size:var(--text-sm); text-align:center; padding:var(--space-6) 0; }
+.svc-results-list { display:flex; flex-direction:column; gap:var(--space-3); }
+.svc-card { display:flex; align-items:flex-start; gap:var(--space-3); padding:var(--space-4); background:var(--c-surface); border-radius:var(--radius-lg); border:1px solid var(--c-border-light); box-shadow:var(--shadow-xs); }
+.svc-card-avatar { width:44px; height:44px; border-radius:var(--radius-full); background:var(--c-primary-subtle); color:var(--c-primary); display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; }
+.svc-card-body { flex:1; min-width:0; }
+.svc-card-name { font-weight:var(--weight-semibold); margin-bottom:var(--space-1); }
+.svc-card-dist { font-size:var(--text-xs); color:var(--c-text-muted); margin-bottom:var(--space-1); }
+.svc-card-desc { font-size:var(--text-sm); color:var(--c-text-secondary); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
+.svc-card-side { display:flex; flex-direction:column; align-items:flex-end; gap:var(--space-2); flex-shrink:0; }
+.svc-card-price { font-weight:var(--weight-bold); color:var(--c-primary); font-size:var(--text-sm); }
+
+/* ============================================================
+ HELP TOOLTIP
+ ============================================================ */
+.by-help-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px; height: 18px;
+ border-radius: 50%;
+ background: var(--c-surface-2);
+ color: var(--c-text-secondary);
+ border: none;
+ cursor: pointer;
+ vertical-align: middle;
+ margin-left: 4px;
+ flex-shrink: 0;
+ transition: background .15s;
+}
+.by-help-btn:hover { background: var(--c-primary-subtle, #e8f0fe); color: var(--c-primary); }
+
+.by-help-tooltip {
+ position: absolute;
+ z-index: 9000;
+ background: var(--c-text);
+ color: var(--c-bg);
+ font-size: var(--text-xs);
+ line-height: 1.5;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ max-width: 240px;
+ box-shadow: 0 4px 12px rgba(0,0,0,.15);
+ pointer-events: none;
+}
+/* SVG-Icon-Variante (Phosphor) */
+.empty-state-icon > svg.ph-icon,
+svg.empty-state-icon {
+ width: 56px;
+ height: 56px;
+ color: var(--c-text-muted);
+ opacity: .5;
+}
+.empty-state-cta {
+ margin-top: var(--space-2);
+}
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index 431e9bb..1f46743 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -139,6 +139,9 @@ const API = (() => {
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
+ gewichtVerlauf(dogId) {
+ return get(`/dogs/${dogId}/health/gewicht`);
+ },
};
// ----------------------------------------------------------
@@ -230,10 +233,13 @@ const API = (() => {
const q = new URLSearchParams(params).toString();
return get(`/events${q ? '?' + q : ''}`);
},
- get(id) { return get(`/events/${id}`); },
- create(data) { return post('/events', data); },
- update(id, data) { return patch(`/events/${id}`, data); },
- delete(id) { return del(`/events/${id}`); },
+ get(id) { return get(`/events/${id}`); },
+ create(data) { return post('/events', data); },
+ update(id, data) { return patch(`/events/${id}`, data); },
+ delete(id) { return del(`/events/${id}`); },
+ rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
+ cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
+ listRsvp(id) { return get(`/events/${id}/rsvp`); },
};
// ----------------------------------------------------------
@@ -445,6 +451,20 @@ const API = (() => {
delete(id) { return del(`/notifications/${id}`); },
};
+ // ----------------------------------------------------------
+ // SERVICE-ANGEBOTE (Sitting & Walks Matching)
+ // ----------------------------------------------------------
+ const services = {
+ list(type, lat = null, lon = null, radius = 20) {
+ const p = new URLSearchParams({ type, radius });
+ if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
+ return get(`/services?${p}`);
+ },
+ me() { return get('/services/me'); },
+ upsert(data) { return post('/services', data); },
+ deactivate(id) { return del(`/services/${id}`); },
+ };
+
const importData = {
notestation(dogId, file) {
const fd = new FormData();
@@ -477,7 +497,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
- friends, chat, webcal, importData, sharing, widget, notifications,
+ friends, chat, webcal, importData, sharing, widget, notifications, services,
subscribeToPush, getLocation,
APIError,
};
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 1399a7a..5033d89 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '129'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index c6b7849..77754f8 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -206,6 +206,20 @@ window.Page_diary = (() => {
UI.setLoading(btn, false);
}
+ // ----------------------------------------------------------
+ // EMPTY-STATE HELPER
+ // ----------------------------------------------------------
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
// ----------------------------------------------------------
// LISTE RENDERN — Timeline gruppiert nach Monat
// ----------------------------------------------------------
@@ -214,12 +228,12 @@ window.Page_diary = (() => {
if (!listEl) return;
if (_entries.length === 0) {
- listEl.innerHTML = UI.emptyState({
- icon: '',
- title: 'Noch keine Einträge',
- text: 'Halte besondere Momente mit deinem Hund fest.',
- action: ``,
- });
+ listEl.innerHTML = _emptyState(
+ 'book-open',
+ 'Noch keine Tagebucheinträge',
+ 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
+ ``
+ );
listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null));
return;
diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js
index 397ba6e..c1a4ce5 100644
--- a/backend/static/js/pages/dog-profile.js
+++ b/backend/static/js/pages/dog-profile.js
@@ -582,6 +582,7 @@ window.Page_dog_profile = (() => {
{
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
-
+
diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js
index 6c55686..333ab72 100644
--- a/backend/static/js/pages/events.js
+++ b/backend/static/js/pages/events.js
@@ -39,6 +39,7 @@ window.Page_events = (() => {
let _map = null;
let _markers = [];
let _clusterGroup = null;
+ let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
// ----------------------------------------------------------
// Phosphor-Icon-Helper
@@ -47,6 +48,17 @@ window.Page_events = (() => {
return ``;
}
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
@@ -133,7 +145,11 @@ window.Page_events = (() => {
const filtered = _filtered();
if (!filtered.length) {
- listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
+ listEl.innerHTML = _emptyState(
+ 'calendar-blank',
+ 'Keine Events in der Nähe',
+ 'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
+ );
return;
}
@@ -185,6 +201,7 @@ window.Page_events = (() => {
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
+ ${ev.rsvp_count ? `${_icon('users')} ${ev.rsvp_count} nehmen teil` : ''}
${ev.link ? `
${_icon('arrow-square-out')} Details
@@ -326,12 +343,26 @@ window.Page_events = (() => {
let ev;
try { ev = await API.events.get(id); } catch { return; }
- const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
- const color = TYP_COLOR[ev.typ] || '#6b7280';
- const d = new Date(ev.datum + 'T00:00:00');
- const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
- const isOwn = _state.user?.id === ev.user_id;
- const isVdh = ev.quelle === 'vdh';
+ const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
+ const color = TYP_COLOR[ev.typ] || '#6b7280';
+ const d = new Date(ev.datum + 'T00:00:00');
+ const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
+ const isOwn = _state.user?.id === ev.user_id;
+ const isVdh = ev.quelle === 'vdh';
+ const myRsvp = _myRsvp[id] ?? null;
+
+ // RSVP-Bar (nur für eingeloggte User)
+ const rsvpBar = _state.user ? `
+
+
+
+ ${ev.rsvp_count ? `${_icon('users')} ${ev.rsvp_count} nehmen teil` : `${_icon('users')} 0 nehmen teil`}
+
+ ` : (ev.rsvp_count ? `${_icon('users')} ${ev.rsvp_count} nehmen teil
` : '');
const body = `
@@ -348,6 +379,8 @@ window.Page_events = (() => {
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
+ ${rsvpBar}
+
`;
const footer = isOwn ? `
@@ -365,6 +398,80 @@ window.Page_events = (() => {
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
});
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
+
+ // RSVP-Buttons
+ document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
+ btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
+ });
+ }
+
+ async function _handleRsvp(eventId, status) {
+ const current = _myRsvp[eventId] ?? null;
+ try {
+ if (current === status) {
+ // Toggle off → absagen
+ await API.events.cancelRsvp(eventId);
+ _myRsvp[eventId] = null;
+ } else {
+ const res = await API.events.rsvp(eventId, status);
+ _myRsvp[eventId] = status;
+ // Teilnehmerzähler aktualisieren
+ _updateAttendeeCount(eventId, res.rsvp_count);
+ }
+ // Button-Styles aktualisieren
+ document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
+ });
+ // Bei Absage Zähler neu laden
+ if (current === status) {
+ const attendees = await API.events.listRsvp(eventId);
+ const goingCount = attendees.filter(a => a.status === 'going').length;
+ _updateAttendeeCount(eventId, goingCount);
+ }
+ } catch (e) { UI.toast(e.message, 'error'); }
+ }
+
+ function _updateAttendeeCount(eventId, count) {
+ // Im Modal
+ const span = document.getElementById(`ev-attendees-${eventId}`);
+ if (span) {
+ if (count > 0) {
+ span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
+ span.style.display = '';
+ } else {
+ span.style.display = 'none';
+ }
+ }
+ // In der Listenansicht (Event-Objekt aktualisieren)
+ const ev = _events.find(x => x.id === eventId);
+ if (ev) {
+ ev.rsvp_count = count;
+ // Karte neu rendern falls sichtbar
+ const card = document.querySelector(`[data-ev-id="${eventId}"]`);
+ if (card) card.outerHTML = _cardHTML(ev);
+ }
+ }
+
+ async function _showAttendees(eventId) {
+ const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
+ if (!panel) return;
+ if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
+ try {
+ const attendees = await API.events.listRsvp(eventId);
+ if (!attendees.length) { panel.innerHTML = '
Noch keine Zusagen.
'; }
+ else {
+ panel.innerHTML = `
+
+ ${attendees.map(a => `
+
+ ${a.status === 'going' ? _icon('check-circle') : _icon('question')}
+ ${UI.escHtml(a.name)}
+
+ `).join('')}
+
`;
+ }
+ panel.dataset.loaded = '1';
+ } catch { /* ignore */ }
}
async function _deleteEvent(ev) {
@@ -548,6 +655,14 @@ window.Page_events = (() => {
return;
}
+ // Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
+ const attendeesBtn = e.target.closest('[data-ev-attendees]');
+ if (attendeesBtn) {
+ e.stopPropagation();
+ _showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
+ return;
+ }
+
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js
index 3b1a4f9..3dd4b00 100644
--- a/backend/static/js/pages/friends.js
+++ b/backend/static/js/pages/friends.js
@@ -366,19 +366,15 @@ window.Page_friends = (() => {
const el = _container.querySelector('#fr-list');
if (!list.length) {
- el.innerHTML = `
-
-
-
Noch keine Hundefreunde
-
- Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
-
-
- `;
+ el.innerHTML = _emptyState(
+ 'users-three',
+ 'Noch keine Freunde',
+ 'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
+ `
`
+ );
+ el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
+ _container.querySelector('#fr-search')?.focus();
+ });
return;
}
@@ -774,6 +770,17 @@ window.Page_friends = (() => {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
// ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js
index 2f36caa..8bc709b 100644
--- a/backend/static/js/pages/health.js
+++ b/backend/static/js/pages/health.js
@@ -321,6 +321,11 @@ window.Page_health = (() => {
} catch (err) {
// silent fail
}
+ try {
+ _data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
+ } catch (err) {
+ _data['gewicht_chart'] = [];
+ }
}
// ----------------------------------------------------------
@@ -347,15 +352,33 @@ window.Page_health = (() => {
_bindTabEvents(content);
}
+ // ----------------------------------------------------------
+ // EMPTY-STATE HELPER
+ // ----------------------------------------------------------
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
// ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status
// ----------------------------------------------------------
function _renderImpfungen(entries) {
const addBtn = `
`;
+ const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
- if (!entries.length) return UI.emptyState({
- icon: '
', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
- });
+ if (!entries.length) return _emptyState(
+ 'syringe',
+ 'Noch keine Impfungen',
+ `Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
+ addBtn
+ );
const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes);
@@ -453,7 +476,8 @@ window.Page_health = (() => {
`;
})() : '';
- const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
+ const chartEntries = _data['gewicht_chart'] || [];
+ const chart = _renderWeightChart(chartEntries);
const items = sorted.slice().reverse().map(e => `
{
${deltaHtml}
- ${chart ? `${chart}
` : ''}
+ ${chart ? `
+
+ Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
+
+ ${chart}
+
` : ''}
${items}
${addBtn}
`;
@@ -556,6 +586,62 @@ window.Page_health = (() => {
`;
}
+ // ----------------------------------------------------------
+ // GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
+ // ----------------------------------------------------------
+ function _renderWeightChart(entries) {
+ // entries: [{datum, gewicht}, ...]
+ if (!entries || entries.length < 2) {
+ return 'Mindestens 2 Gewichtseinträge für den Verlauf nötig.
';
+ }
+
+ const W = 300, H = 120, PAD = 24;
+ const weights = entries.map(e => e.gewicht);
+ const min = Math.min(...weights), max = Math.max(...weights);
+ const range = max - min || 1;
+
+ // x: gleichmäßig verteilt, y: normalisiert
+ const pts = entries.map((e, i) => {
+ const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
+ const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
+ return { x, y, ...e };
+ });
+
+ const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
+ const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
+
+ // Datenpunkte + Tooltips als title-Elemente
+ const circles = pts.map(p =>
+ `
+ ${p.datum}: ${p.gewicht} kg
+ `
+ ).join('');
+
+ const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
+ return `
+
+
Gewichtsverlauf
+
+
+ ${entries[0].datum}
+ ${entries[entries.length - 1].datum}
+
+
+ `;
+ }
+
// ----------------------------------------------------------
// LÄUFIGKEIT — Timeline + Vorhersage
// ----------------------------------------------------------
@@ -927,7 +1013,10 @@ window.Page_health = (() => {
const uploadField = t === 'dokument' ? `
-
+
` : '';
diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js
index 36c405e..b65bcf4 100644
--- a/backend/static/js/pages/lost.js
+++ b/backend/static/js/pages/lost.js
@@ -257,12 +257,12 @@ window.Page_lost = (() => {
if (!listEl) return;
if (_reports.length === 0) {
- listEl.innerHTML = UI.emptyState({
- icon : '🐾',
- title : 'Keine vermissten Hunde',
- text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
- action: ``,
- });
+ listEl.innerHTML = _emptyState(
+ 'magnifying-glass',
+ 'Aktuell kein vermisster Hund gemeldet',
+ 'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.',
+ ``
+ );
listEl.querySelector('#lost-empty-report')
?.addEventListener('click', _showReportForm);
return;
@@ -680,6 +680,17 @@ window.Page_lost = (() => {
.replace(/"/g, '"');
}
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js
index a86c51d..fad314e 100644
--- a/backend/static/js/pages/routes.js
+++ b/backend/static/js/pages/routes.js
@@ -45,13 +45,31 @@ window.Page_routes = (() => {
return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
+ function _emptyState(icon, title, text, cta = '') {
+ return `
+
+
${title}
+ ${text ? `
${text}
` : ''}
+ ${cta ? `
${cta}
` : ''}
+
`;
+ }
+
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
try { _userPos = await API.getLocation(); } catch {}
- _loadData();
+ await _loadData();
+
+ // Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen
+ const params = new URLSearchParams((location.hash.split('?')[1] || ''));
+ const deepId = params.get('id');
+ if (deepId) {
+ _openDetail(parseInt(deepId, 10));
+ }
}
async function _loadLeaflet() {
@@ -460,20 +478,12 @@ window.Page_routes = (() => {
`;
} else {
// Noch gar keine eigenen Routen
- grid.innerHTML = `
-
🥾
-
Deine erste Gassi-Route
-
Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.
-
-
${UI.icon('map-trifold')}GPS-Aufzeichnung
-
${UI.icon('camera')}Fotos entlang der Strecke
-
🐾Hundetauglichkeit bewerten
-
${UI.icon('download-simple')}GPX-Download für Navi
-
${UI.icon('map-pin')}Restaurants & Parkplätze
-
${UI.icon('lock')}Privat oder öffentlich
-
-
-
`;
+ grid.innerHTML = _emptyState(
+ 'map-trifold',
+ 'Noch keine Routen',
+ 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
+ ``
+ );
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
@@ -688,6 +698,8 @@ window.Page_routes = (() => {
const footer = `
+
+
${isOwn ? `
@@ -700,6 +712,23 @@ window.Page_routes = (() => {
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
+ // Teilen-Button
+ document.getElementById('rd-share')?.addEventListener('click', () => {
+ const shareUrl = location.origin + '/#routes?id=' + route.id;
+ if (navigator.share) {
+ navigator.share({ title: route.name, url: shareUrl }).catch(() => {});
+ } else {
+ navigator.clipboard.writeText(shareUrl).then(() => {
+ UI.toast.success('Link kopiert!');
+ }).catch(() => {
+ UI.toast.error('Link konnte nicht kopiert werden.');
+ });
+ }
+ });
+
+ // An Freund senden
+ document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route));
+
// Sichtbarkeit toggle
document.getElementById('rd-vis')?.addEventListener('click', async () => {
try {
@@ -1108,6 +1137,7 @@ window.Page_routes = (() => {
@@ -1173,6 +1203,71 @@ window.Page_routes = (() => {
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
}
+ // ----------------------------------------------------------
+ // An Freund senden
+ // ----------------------------------------------------------
+ async function _openSendToFriendModal(route) {
+ const shareUrl = location.origin + '/#routes?id=' + route.id;
+
+ // Freunde laden
+ let friends = [];
+ try {
+ friends = await API.friends.list();
+ } catch (err) {
+ UI.toast.error('Freunde konnten nicht geladen werden.');
+ return;
+ }
+
+ if (!friends.length) {
+ UI.toast.info('Du hast noch keine Freunde hinzugefügt.');
+ return;
+ }
+
+ const friendRows = friends.map(f => {
+ const initial = (f.name || '?')[0].toUpperCase();
+ return `
+
${_esc(initial)}
+
${_esc(f.name || 'Anonym')}
+
`;
+ }).join('');
+
+ const body = `${friendRows}
`;
+ const footer = ``;
+
+ UI.modal.open({
+ title: `${UI.icon('chat-circle-dots')} An Freund senden`,
+ body,
+ footer,
+ });
+
+ document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
+
+ document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
+ const row = e.target.closest('.rk-friend-row');
+ if (!row) return;
+
+ const partnerId = parseInt(row.dataset.id, 10);
+ const partnerName = row.dataset.name;
+
+ try {
+ const conv = await API.chat.start(partnerId);
+ const convId = conv.id;
+ const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
+ await API.chat.send(convId, text);
+ UI.modal.close();
+ UI.toast.success(`Gesendet an ${partnerName}`);
+ } catch (err) {
+ UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
+ }
+ });
+ }
+
return { init, refresh, onDogChange };
})();
diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js
index 4573799..5f1e516 100644
--- a/backend/static/js/pages/sitting.js
+++ b/backend/static/js/pages/sitting.js
@@ -18,13 +18,17 @@ window.Page_sitting = (() => {
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
- let _container = null;
- let _state = null;
- let _tab = 'suchen'; // suchen | profil | anfragen
- let _sitters = [];
- let _mySitter = null;
- let _myRequests = [];
- let _inbox = [];
+ let _container = null;
+ let _state = null;
+ let _tab = 'suchen'; // suchen | profil | anfragen | matching
+ let _sitters = [];
+ let _mySitter = null;
+ let _myRequests = [];
+ let _inbox = [];
+ // Matching-State
+ let _matchResults = null; // null = noch nicht geladen
+ let _matchLoading = false;
+ let _myServiceOffer = null; // eigenes Angebot (type='sitting')
// ----------------------------------------------------------
// init
@@ -46,6 +50,7 @@ window.Page_sitting = (() => {
+
${_state.user ? `
@@ -70,6 +75,7 @@ window.Page_sitting = (() => {
tasks.push(API.sitting.me());
tasks.push(API.sitting.requests());
tasks.push(API.sitting.inbox());
+ tasks.push(API.services.me());
}
try {
@@ -78,6 +84,8 @@ window.Page_sitting = (() => {
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
+ const myOffers = results[4]?.status === 'fulfilled' ? results[4].value : [];
+ _myServiceOffer = myOffers?.find(o => o.type === 'sitting') || null;
} catch {}
_renderTab();
@@ -92,6 +100,7 @@ window.Page_sitting = (() => {
if (_tab === 'suchen') _renderSuchen(content);
if (_tab === 'profil') _renderProfil(content);
if (_tab === 'anfragen') _renderAnfragen(content);
+ if (_tab === 'matching') _renderMatching(content);
}
// ---- Tab: Sitter suchen ----
@@ -441,6 +450,239 @@ window.Page_sitting = (() => {
});
}
+ // ----------------------------------------------------------
+ // Tab: Anbieter in deiner Nähe (service_offers Matching)
+ // ----------------------------------------------------------
+ function _renderMatching(el) {
+ const offerActive = _myServiceOffer?.aktiv;
+ const offerDesc = _myServiceOffer?.beschreibung || '';
+ const offerPreis = _myServiceOffer?.preis_pro_tag ?? '';
+
+ el.innerHTML = `
+
+
+
+
+
+ ${_state.user ? `
+
+ ` : ''}
+
+
+
+
+
+
+
Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.
+
+
+
+
+ `;
+
+ // Toggle
+ document.getElementById('svc-offer-toggle')?.addEventListener('change', async e => {
+ const form = document.getElementById('svc-offer-form');
+ if (e.target.checked) {
+ form?.classList.remove('svc-offer-form--hidden');
+ } else {
+ form?.classList.add('svc-offer-form--hidden');
+ if (_myServiceOffer) {
+ try {
+ await API.services.deactivate(_myServiceOffer.id);
+ _myServiceOffer = { ..._myServiceOffer, aktiv: 0 };
+ UI.toast('Angebot deaktiviert.');
+ } catch (err) { UI.toast(err.message, 'error'); }
+ }
+ }
+ });
+
+ // GPS
+ document.getElementById('svc-gps-btn')?.addEventListener('click', async () => {
+ const btn = document.getElementById('svc-gps-btn');
+ btn.disabled = true;
+ try {
+ const pos = await API.getLocation();
+ document.getElementById('svc-lat').value = pos.lat.toFixed(6);
+ document.getElementById('svc-lon').value = pos.lon.toFixed(6);
+ UI.toast('Position gespeichert.');
+ } catch { UI.toast('GPS nicht verfügbar.', 'error'); }
+ btn.disabled = false;
+ });
+
+ // Formular speichern
+ document.getElementById('svc-offer-form')?.addEventListener('submit', async e => {
+ e.preventDefault();
+ const form = e.target;
+ const fd = new FormData(form);
+ const submitBtn = form.querySelector('[type="submit"]');
+
+ // Wenn noch keine Position gespeichert, GPS holen
+ if (!fd.get('lat') || !fd.get('lon')) {
+ try {
+ const pos = await API.getLocation();
+ document.getElementById('svc-lat').value = pos.lat.toFixed(6);
+ document.getElementById('svc-lon').value = pos.lon.toFixed(6);
+ fd.set('lat', pos.lat.toFixed(6));
+ fd.set('lon', pos.lon.toFixed(6));
+ } catch {
+ UI.toast('Bitte GPS-Position ermitteln.', 'error');
+ return;
+ }
+ }
+
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; }
+ try {
+ const payload = {
+ type: 'sitting',
+ beschreibung: fd.get('beschreibung') || null,
+ preis_pro_tag: fd.get('preis_pro_tag') ? parseFloat(fd.get('preis_pro_tag')) : null,
+ lat: parseFloat(fd.get('lat')),
+ lon: parseFloat(fd.get('lon')),
+ radius_km: parseInt(fd.get('radius_km')) || 10,
+ };
+ _myServiceOffer = await API.services.upsert(payload);
+ UI.toast('Angebot gespeichert!');
+ } catch (err) {
+ UI.toast(err.message, 'error');
+ } finally {
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.innerHTML = `${UI.icon('floppy-disk')} Speichern`;
+ }
+ }
+ });
+
+ // Suche
+ document.getElementById('svc-find-btn')?.addEventListener('click', _searchProviders);
+ }
+
+ async function _searchProviders() {
+ if (_matchLoading) return;
+ _matchLoading = true;
+ const btn = document.getElementById('svc-find-btn');
+ if (btn) { btn.disabled = true; btn.innerHTML = `${UI.icon('spinner')} Suche…`; }
+
+ const resultsEl = document.getElementById('svc-results');
+
+ let pos = null;
+ try {
+ pos = await API.getLocation();
+ } catch {
+ UI.toast('GPS nicht verfügbar — Suche ohne Entfernung.', 'error');
+ }
+
+ try {
+ const offers = await API.services.list('sitting', pos?.lat ?? null, pos?.lon ?? null, 50);
+ _matchResults = offers;
+ _renderMatchResults(resultsEl, pos);
+ } catch (err) {
+ UI.toast(err.message, 'error');
+ if (resultsEl) resultsEl.innerHTML = `
Fehler beim Laden.
`;
+ }
+
+ _matchLoading = false;
+ if (btn) {
+ btn.disabled = false;
+ btn.innerHTML = `${UI.icon('map-pin')} Erneut suchen`;
+ }
+ }
+
+ function _renderMatchResults(el, pos) {
+ if (!el) return;
+ const list = _matchResults || [];
+
+ // Eigene Angebote ausblenden
+ const filtered = list.filter(o => o.user_id !== _state.user?.id);
+
+ if (!filtered.length) {
+ el.innerHTML = `
Keine Anbieter in deiner Nähe gefunden.
`;
+ return;
+ }
+
+ el.innerHTML = `
+
+ ${filtered.map(o => _serviceCardHTML(o)).join('')}
+
+ `;
+ }
+
+ function _serviceCardHTML(o) {
+ const dist = o.distanz_km != null ? `${o.distanz_km} km entfernt` : '';
+ const preis = o.preis_pro_tag != null ? `${o.preis_pro_tag.toFixed(0)} €/Tag` : 'Preis anfragen';
+ return `
+
+
${UI.icon('paw-print')}
+
+
${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}
+ ${dist ? `
${UI.icon('map-pin')} ${dist}
` : ''}
+ ${o.beschreibung ? `
${UI.escape(o.beschreibung)}
` : ''}
+
+
+
${preis}
+
+
+
+ `;
+ }
+
+ async function _openChatWithProvider(userId) {
+ if (!_state.user) {
+ UI.toast('Bitte zuerst anmelden.', 'error');
+ App.navigate('settings');
+ return;
+ }
+ try {
+ const { conversation_id } = await API.chat.start(userId);
+ App.navigate('chat', true, { conversation_id });
+ } catch (err) {
+ UI.toast(err.message, 'error');
+ }
+ }
+
// ----------------------------------------------------------
// Click-Handler
// ----------------------------------------------------------
@@ -467,6 +709,13 @@ window.Page_sitting = (() => {
return;
}
+ // Anbieter kontaktieren
+ const chatBtn = e.target.closest('[data-svc-chat]');
+ if (chatBtn) {
+ _openChatWithProvider(parseInt(chatBtn.dataset.svcChat));
+ return;
+ }
+
// Anfragen-Aktionen
const acceptBtn = e.target.closest('[data-sit-accept]');
const declineBtn = e.target.closest('[data-sit-decline]');
diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js
index 8f1101e..52d21fd 100644
--- a/backend/static/js/ui.js
+++ b/backend/static/js/ui.js
@@ -268,6 +268,37 @@ const UI = (() => {
.replace(/"/g, '"');
}
+ // ----------------------------------------------------------
+ // HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
+ // ----------------------------------------------------------
+ function help(text) {
+ return `
`;
+ }
+
+ // Event-Delegation für Help-Tooltips — einmalig registrieren
+ document.addEventListener('click', e => {
+ const btn = e.target.closest('.by-help-btn');
+ if (!btn) {
+ document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
+ return;
+ }
+ e.stopPropagation();
+ document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
+ const tip = document.createElement('div');
+ tip.className = 'by-help-tooltip';
+ tip.textContent = btn.dataset.help;
+ document.body.appendChild(tip);
+ const r = btn.getBoundingClientRect();
+ tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
+ tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
+ const maxL = window.innerWidth - tip.offsetWidth - 8;
+ if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
+ });
+
// Öffentliche API
return {
toast, modal,
@@ -276,7 +307,7 @@ const UI = (() => {
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
- escape,
+ escape, help,
};
})();
diff --git a/backend/static/sw.js b/backend/static/sw.js
index dcde7ef..7296707 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v156';
+const CACHE_VERSION = 'by-v157';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten