Sprint 19: Social, UX-Verbesserungen, Nerd2Noob-Hilfe

This commit is contained in:
rene 2026-04-17 23:53:50 +02:00
parent 10d30bf565
commit 89d87030a2
18 changed files with 930 additions and 74 deletions

View file

@ -675,6 +675,20 @@ def _migrate(conn_factory):
""") """)
logger.info("Migration: dog_shares Tabelle bereit.") 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) # 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()} _ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
if _ev_cols.get("user_id") == 1: 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; ON events(external_id) WHERE external_id IS NOT NULL;
""") """)
logger.info("Migration: events.user_id NOT NULL Constraint entfernt.") 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);
""")

View file

@ -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.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
from routes.widget import router as widget_router from routes.widget import router as widget_router
from routes.notifications import router as notifications_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(sharing_share_router, prefix="/api/share", tags=["Teilen"])
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(services_router, prefix="/api/services", tags=["Services"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -25,6 +25,9 @@ def _haversine(lat1, lon1, lat2, lon2):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RsvpCreate(BaseModel):
status: str = 'going' # 'going' | 'maybe'
class EventCreate(BaseModel): class EventCreate(BaseModel):
titel: str titel: str
datum: str # YYYY-MM-DD datum: str # YYYY-MM-DD
@ -65,7 +68,8 @@ async def list_events(
q = """ q = """
SELECT e.*, SELECT e.*,
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, 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 FROM events e
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
WHERE e.status = 'aktiv' 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.lat, data.lon, data.ort_name,
data.typ, data.beschreibung, data.link)) data.typ, data.beschreibung, data.link))
row = conn.execute( 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 = ?", "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(cur.lastrowid,) (cur.lastrowid,)
).fetchone() ).fetchone()
@ -115,7 +120,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
async def get_event(event_id: int): async def get_event(event_id: int):
with db() as conn: with db() as conn:
row = conn.execute( 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 = ?", "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,) (event_id,)
).fetchone() ).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) cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id]) conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
row = conn.execute( 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 = ?", "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,) (event_id,)
).fetchone() ).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']: if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.") raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,)) 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]

View file

@ -1,11 +1,14 @@
"""BAN YARO — Forum (Sprint 11)""" """BAN YARO — Forum (Sprint 11)"""
import os, uuid, json import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from routes.push import send_push_to_user
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -295,9 +298,30 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
WHERE p.id = ?""", WHERE p.id = ?""",
(cur.lastrowid,) (cur.lastrowid,)
).fetchone() ).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 = dict(row)
pd['foto_urls'] = [] pd['foto_urls'] = []
pd['user_liked'] = False 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 return pd

View file

@ -243,6 +243,22 @@ async def upload_dokument(
return {"datei_url": datei_url, "datei_typ": datei_typ} 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 # POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -5044,3 +5044,93 @@ textarea.form-control {
color: var(--c-text-muted); color: var(--c-text-muted);
margin-top: 2px; 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);
}

View file

@ -139,6 +139,9 @@ const API = (() => {
symptomCheck(dogId, symptoms) { symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { 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(); const q = new URLSearchParams(params).toString();
return get(`/events${q ? '?' + q : ''}`); return get(`/events${q ? '?' + q : ''}`);
}, },
get(id) { return get(`/events/${id}`); }, get(id) { return get(`/events/${id}`); },
create(data) { return post('/events', data); }, create(data) { return post('/events', data); },
update(id, data) { return patch(`/events/${id}`, data); }, update(id, data) { return patch(`/events/${id}`, data); },
delete(id) { return del(`/events/${id}`); }, 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}`); }, 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 = { const importData = {
notestation(dogId, file) { notestation(dogId, file) {
const fd = new FormData(); const fd = new FormData();
@ -477,7 +497,7 @@ const API = (() => {
get, post, put, patch, del, upload, get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison, auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, 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, subscribeToPush, getLocation,
APIError, APIError,
}; };

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

@ -206,6 +206,20 @@ window.Page_diary = (() => {
UI.setLoading(btn, false); UI.setLoading(btn, false);
} }
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// LISTE RENDERN — Timeline gruppiert nach Monat // LISTE RENDERN — Timeline gruppiert nach Monat
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -214,12 +228,12 @@ window.Page_diary = (() => {
if (!listEl) return; if (!listEl) return;
if (_entries.length === 0) { if (_entries.length === 0) {
listEl.innerHTML = UI.emptyState({ listEl.innerHTML = _emptyState(
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>', 'book-open',
title: 'Noch keine Einträge', 'Noch keine Tagebucheinträge',
text: 'Halte besondere Momente mit deinem Hund fest.', 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`, `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
}); );
listEl.querySelector('#diary-first-entry') listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null)); ?.addEventListener('click', () => _showForm(null));
return; return;

View file

@ -582,6 +582,7 @@ window.Page_dog_profile = (() => {
<label class="form-label"> <label class="form-label">
Rasse Rasse
<span style="color:var(--c-text-secondary)">(optional)</span> <span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Die Rasse wird für Rasseninformationen und Statistiken verwendet.')}
</label> </label>
<input class="form-control" type="text" name="rasse" <input class="form-control" type="text" name="rasse"
value="${_esc(dog?.rasse || '')}" value="${_esc(dog?.rasse || '')}"
@ -612,7 +613,10 @@ window.Page_dog_profile = (() => {
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5"> min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Chip-Nummer</label> <label class="form-label">
Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label>
<input class="form-control" type="text" name="chip_nr" <input class="form-control" type="text" name="chip_nr"
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig"> value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
</div> </div>

View file

@ -39,6 +39,7 @@ window.Page_events = (() => {
let _map = null; let _map = null;
let _markers = []; let _markers = [];
let _clusterGroup = null; let _clusterGroup = null;
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
// ---------------------------------------------------------- // ----------------------------------------------------------
// Phosphor-Icon-Helper // Phosphor-Icon-Helper
@ -47,6 +48,17 @@ window.Page_events = (() => {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`; return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
} }
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// init // init
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -133,7 +145,11 @@ window.Page_events = (() => {
const filtered = _filtered(); const filtered = _filtered();
if (!filtered.length) { 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; return;
} }
@ -185,6 +201,7 @@ window.Page_events = (() => {
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''} ${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''} ${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
</div> </div>
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
${ev.link ? `<div class="events-card-actions"> ${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()"> <a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details ${_icon('arrow-square-out')} Details
@ -326,12 +343,26 @@ window.Page_events = (() => {
let ev; let ev;
try { ev = await API.events.get(id); } catch { return; } try { ev = await API.events.get(id); } catch { return; }
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280'; const color = TYP_COLOR[ev.typ] || '#6b7280';
const d = new Date(ev.datum + 'T00:00:00'); const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id; const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh'; const isVdh = ev.quelle === 'vdh';
const myRsvp = _myRsvp[id] ?? null;
// RSVP-Bar (nur für eingeloggte User)
const rsvpBar = _state.user ? `
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
${_icon('check-circle')} Ich komme
</button>
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
${_icon('question')} Vielleicht
</button>
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
</div>
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
const body = ` const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
@ -348,6 +379,8 @@ window.Page_events = (() => {
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)"> <div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')} ${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}
</div> </div>
${rsvpBar}
<div id="ev-attendees-panel-${id}"></div>
`; `;
const footer = isOwn ? ` const footer = isOwn ? `
@ -365,6 +398,80 @@ window.Page_events = (() => {
UI.modal.close(); setTimeout(() => _openForm(ev), 50); UI.modal.close(); setTimeout(() => _openForm(ev), 50);
}); });
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev)); 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 = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
else {
panel.innerHTML = `
<div class="ev-attendees-list">
${attendees.map(a => `
<span class="ev-attendee-chip">
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
${UI.escHtml(a.name)}
</span>
`).join('')}
</div>`;
}
panel.dataset.loaded = '1';
} catch { /* ignore */ }
} }
async function _deleteEvent(ev) { async function _deleteEvent(ev) {
@ -548,6 +655,14 @@ window.Page_events = (() => {
return; 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 // Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]'); const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); } if (card) { _showDetail(parseInt(card.dataset.evId)); }

View file

@ -366,19 +366,15 @@ window.Page_friends = (() => {
const el = _container.querySelector('#fr-list'); const el = _container.querySelector('#fr-list');
if (!list.length) { if (!list.length) {
el.innerHTML = ` el.innerHTML = _emptyState(
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> 'users-three',
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border); 'Noch keine Freunde',
margin-bottom:var(--space-3)" aria-hidden="true"> 'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
<use href="/icons/phosphor.svg#paw-print"></use> `<button class="btn btn-primary" id="fr-empty-search">Freunde suchen</button>`
</svg> );
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold); el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p> _container.querySelector('#fr-search')?.focus();
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0"> });
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
</p>
</div>
`;
return; return;
} }
@ -774,6 +770,17 @@ window.Page_friends = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat }; return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };

View file

@ -321,6 +321,11 @@ window.Page_health = (() => {
} catch (err) { } catch (err) {
// silent fail // 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); _bindTabEvents(content);
} }
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// IMPFUNGEN — mit Ampel-Status // IMPFUNGEN — mit Ampel-Status
// ---------------------------------------------------------- // ----------------------------------------------------------
function _renderImpfungen(entries) { function _renderImpfungen(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`; const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
if (!entries.length) return UI.emptyState({ if (!entries.length) return _emptyState(
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn 'syringe',
}); 'Noch keine Impfungen',
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
addBtn
);
const items = entries.map(e => { const items = entries.map(e => {
const ampel = _impfAmpel(e.naechstes); const ampel = _impfAmpel(e.naechstes);
@ -453,7 +476,8 @@ window.Page_health = (() => {
</div>`; </div>`;
})() : ''; })() : '';
const chart = sorted.length >= 2 ? _weightChart(sorted) : ''; const chartEntries = _data['gewicht_chart'] || [];
const chart = _renderWeightChart(chartEntries);
const items = sorted.slice().reverse().map(e => ` const items = sorted.slice().reverse().map(e => `
<div class="health-card" data-id="${e.id}" data-action="open-entry" <div class="health-card" data-id="${e.id}" data-action="open-entry"
@ -478,7 +502,13 @@ window.Page_health = (() => {
</div> </div>
${deltaHtml} ${deltaHtml}
</div> </div>
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''} ${chart ? `<div class="health-chart-wrap">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
</div>
${chart}
</div>` : ''}
<div class="health-list" style="margin-top:var(--space-2)">${items}</div> <div class="health-list" style="margin-top:var(--space-2)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div> <div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
`; `;
@ -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 '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
}
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 =>
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
<title>${p.datum}: ${p.gewicht} kg</title>
</circle>`
).join('');
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
return `
<div class="health-chart-wrap">
<div class="health-chart-title">Gewichtsverlauf</div>
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
<defs>
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#${gId})"/>
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${circles}
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
</svg>
<div class="health-chart-labels">
<span>${entries[0].datum}</span>
<span>${entries[entries.length - 1].datum}</span>
</div>
</div>
`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// LÄUFIGKEIT — Timeline + Vorhersage // LÄUFIGKEIT — Timeline + Vorhersage
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -927,7 +1013,10 @@ window.Page_health = (() => {
const uploadField = t === 'dokument' ? ` const uploadField = t === 'dokument' ? `
<div class="form-group"> <div class="form-group">
<label class="form-label">Datei (JPG, PNG, PDF)</label> <label class="form-label">
Datei (JPG, PNG, PDF)
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
</label>
<input class="form-control" type="file" name="datei" accept="image/*,.pdf"> <input class="form-control" type="file" name="datei" accept="image/*,.pdf">
</div> </div>
` : ''; ` : '';

View file

@ -257,12 +257,12 @@ window.Page_lost = (() => {
if (!listEl) return; if (!listEl) return;
if (_reports.length === 0) { if (_reports.length === 0) {
listEl.innerHTML = UI.emptyState({ listEl.innerHTML = _emptyState(
icon : '🐾', 'magnifying-glass',
title : 'Keine vermissten Hunde', 'Aktuell kein vermisster Hund gemeldet',
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.', 'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.',
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`, `<button class="btn btn-primary" id="lost-empty-report">Vermissten melden</button>`
}); );
listEl.querySelector('#lost-empty-report') listEl.querySelector('#lost-empty-report')
?.addEventListener('click', _showReportForm); ?.addEventListener('click', _showReportForm);
return; return;
@ -680,6 +680,17 @@ window.Page_lost = (() => {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -45,13 +45,31 @@ window.Page_routes = (() => {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
async function init(container, appState) { async function init(container, appState) {
_container = container; _container = container;
_appState = appState; _appState = appState;
_render(); _render();
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden _loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
try { _userPos = await API.getLocation(); } catch {} 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() { async function _loadLeaflet() {
@ -460,20 +478,12 @@ window.Page_routes = (() => {
</div>`; </div>`;
} else { } else {
// Noch gar keine eigenen Routen // Noch gar keine eigenen Routen
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding"> grid.innerHTML = _emptyState(
<div class="rk-empty-icon">🥾</div> 'map-trifold',
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3> 'Noch keine Routen',
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf mit Streckendaten, Fotos und Hundetauglichkeit.</p> 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
<div class="rk-empty-features"> `<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div> );
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
</div>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
</div>`;
document.getElementById('rk-empty-rec')?.addEventListener('click', () => { document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map'); App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600); setTimeout(() => window.Page_map?.startRecording?.(), 600);
@ -688,6 +698,8 @@ window.Page_routes = (() => {
const footer = ` const footer = `
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button> <button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
<button type="button" class="btn btn-secondary" id="rd-share" title="Route teilen">${UI.icon('share')}</button>
<button type="button" class="btn btn-secondary" id="rd-send-friend" title="An Freund senden">${UI.icon('chat-circle-dots')} An Freund senden</button>
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}"> ${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'} ${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
</button> </button>
@ -700,6 +712,23 @@ window.Page_routes = (() => {
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close); document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route)); 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 // Sichtbarkeit toggle
document.getElementById('rd-vis')?.addEventListener('click', async () => { document.getElementById('rd-vis')?.addEventListener('click', async () => {
try { try {
@ -1108,6 +1137,7 @@ window.Page_routes = (() => {
</label> </label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer"> <label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-public"> Öffentlich <input type="checkbox" id="ri-public"> Öffentlich
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
</label> </label>
</div> </div>
</form> </form>
@ -1173,6 +1203,71 @@ window.Page_routes = (() => {
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]); 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 `<div class="rk-friend-row" data-id="${f.id}" data-name="${_esc(f.name || 'Anonym')}"
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:600;flex-shrink:0">${_esc(initial)}</div>
<span>${_esc(f.name || 'Anonym')}</span>
</div>`;
}).join('');
const body = `<div id="rk-friend-list">${friendRows}</div>`;
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
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 }; return { init, refresh, onDogChange };
})(); })();

View file

@ -18,13 +18,17 @@ window.Page_sitting = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// State // State
// ---------------------------------------------------------- // ----------------------------------------------------------
let _container = null; let _container = null;
let _state = null; let _state = null;
let _tab = 'suchen'; // suchen | profil | anfragen let _tab = 'suchen'; // suchen | profil | anfragen | matching
let _sitters = []; let _sitters = [];
let _mySitter = null; let _mySitter = null;
let _myRequests = []; let _myRequests = [];
let _inbox = []; let _inbox = [];
// Matching-State
let _matchResults = null; // null = noch nicht geladen
let _matchLoading = false;
let _myServiceOffer = null; // eigenes Angebot (type='sitting')
// ---------------------------------------------------------- // ----------------------------------------------------------
// init // init
@ -46,6 +50,7 @@ window.Page_sitting = (() => {
<div class="sitting-layout"> <div class="sitting-layout">
<div class="sitting-tabs by-tabs" id="sit-tabs"> <div class="sitting-tabs by-tabs" id="sit-tabs">
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button> <button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
<button class="by-tab" data-sit-tab="matching">${UI.icon('users')} Anbieter</button>
${_state.user ? ` ${_state.user ? `
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button> <button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button> <button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
@ -70,6 +75,7 @@ window.Page_sitting = (() => {
tasks.push(API.sitting.me()); tasks.push(API.sitting.me());
tasks.push(API.sitting.requests()); tasks.push(API.sitting.requests());
tasks.push(API.sitting.inbox()); tasks.push(API.sitting.inbox());
tasks.push(API.services.me());
} }
try { try {
@ -78,6 +84,8 @@ window.Page_sitting = (() => {
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null; _mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : []; _myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
_inbox = results[3]?.status === 'fulfilled' ? results[3].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 {} } catch {}
_renderTab(); _renderTab();
@ -92,6 +100,7 @@ window.Page_sitting = (() => {
if (_tab === 'suchen') _renderSuchen(content); if (_tab === 'suchen') _renderSuchen(content);
if (_tab === 'profil') _renderProfil(content); if (_tab === 'profil') _renderProfil(content);
if (_tab === 'anfragen') _renderAnfragen(content); if (_tab === 'anfragen') _renderAnfragen(content);
if (_tab === 'matching') _renderMatching(content);
} }
// ---- Tab: Sitter suchen ---- // ---- 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 = `
<div class="svc-matching-layout">
<!-- Eigenes Angebot -->
<div class="svc-own-offer by-card">
<div class="svc-own-offer-header">
<span class="svc-own-offer-title">${UI.icon('handshake')} Mein Angebot</span>
${_state.user ? `
<label class="svc-toggle" title="${offerActive ? 'Angebot deaktivieren' : 'Angebot aktivieren'}">
<input type="checkbox" id="svc-offer-toggle" ${offerActive ? 'checked' : ''}>
<span class="svc-toggle-slider"></span>
</label>
` : `<span class="svc-login-hint">Zum Anbieten bitte anmelden</span>`}
</div>
${_state.user ? `
<form id="svc-offer-form" class="svc-offer-form ${offerActive ? '' : 'svc-offer-form--hidden'}">
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Was bietest du an? Erfahrungen, besondere Stärken…">${UI.escape(offerDesc)}</textarea>
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Preis/Tag (, optional)</label>
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag"
value="${offerPreis}">
</div>
<div class="form-group">
<label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km"
value="${_myServiceOffer?.radius_km ?? 10}">
</div>
</div>
<div class="svc-offer-actions">
<button type="button" class="btn btn-secondary btn-sm" id="svc-gps-btn">
${UI.icon('map-pin')} Position
</button>
<input type="hidden" name="lat" id="svc-lat" value="${_myServiceOffer?.lat || ''}">
<input type="hidden" name="lon" id="svc-lon" value="${_myServiceOffer?.lon || ''}">
<button type="submit" class="btn btn-primary btn-sm">
${UI.icon('floppy-disk')} Speichern
</button>
</div>
</form>
` : ''}
</div>
<!-- Anbieter finden -->
<div class="svc-search-section">
<div class="svc-search-header">
<span class="by-section-label" style="margin:0">${UI.icon('magnifying-glass')} Anbieter in deiner Nähe</span>
<button class="btn btn-primary btn-sm" id="svc-find-btn">
${UI.icon('map-pin')} Suchen
</button>
</div>
<div id="svc-results">
<p class="svc-hint">Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.</p>
</div>
</div>
</div>
`;
// 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 = `<p class="svc-hint">Fehler beim Laden.</p>`;
}
_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 = `<p class="svc-hint">Keine Anbieter in deiner Nähe gefunden.</p>`;
return;
}
el.innerHTML = `
<div class="svc-results-list">
${filtered.map(o => _serviceCardHTML(o)).join('')}
</div>
`;
}
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 `
<div class="svc-card">
<div class="svc-card-avatar">${UI.icon('paw-print')}</div>
<div class="svc-card-body">
<div class="svc-card-name">${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}</div>
${dist ? `<div class="svc-card-dist">${UI.icon('map-pin')} ${dist}</div>` : ''}
${o.beschreibung ? `<div class="svc-card-desc">${UI.escape(o.beschreibung)}</div>` : ''}
</div>
<div class="svc-card-side">
<div class="svc-card-price">${preis}</div>
<button class="btn btn-primary btn-sm" data-svc-chat="${o.user_id}">
${UI.icon('chat-circle')} Kontakt
</button>
</div>
</div>
`;
}
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 // Click-Handler
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -467,6 +709,13 @@ window.Page_sitting = (() => {
return; return;
} }
// Anbieter kontaktieren
const chatBtn = e.target.closest('[data-svc-chat]');
if (chatBtn) {
_openChatWithProvider(parseInt(chatBtn.dataset.svcChat));
return;
}
// Anfragen-Aktionen // Anfragen-Aktionen
const acceptBtn = e.target.closest('[data-sit-accept]'); const acceptBtn = e.target.closest('[data-sit-accept]');
const declineBtn = e.target.closest('[data-sit-decline]'); const declineBtn = e.target.closest('[data-sit-decline]');

View file

@ -268,6 +268,37 @@ const UI = (() => {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
function help(text) {
return `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
<use href="/icons/phosphor.svg#question"></use>
</svg>
</button>`;
}
// 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 // Öffentliche API
return { return {
toast, modal, toast, modal,
@ -276,7 +307,7 @@ const UI = (() => {
emptyState, time, emptyState, time,
setupPhotoPreview, scrollTop, skeleton, setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon, icon: _svgIcon,
escape, escape, help,
}; };
})(); })();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v156'; const CACHE_VERSION = 'by-v157';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten