diff --git a/backend/database.py b/backend/database.py index 3559537..e2712ce 100644 --- a/backend/database.py +++ b/backend/database.py @@ -249,6 +249,52 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- EVENTS (Hundeveranstaltungen) + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + titel TEXT NOT NULL, + datum TEXT NOT NULL, -- YYYY-MM-DD + uhrzeit TEXT, + lat REAL, + lon REAL, + ort_name TEXT, + typ TEXT NOT NULL DEFAULT 'sonstiges', + beschreibung TEXT, + link TEXT, + status TEXT NOT NULL DEFAULT 'aktiv', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC); + + -- SITTING — Sitter-Profile + CREATE TABLE IF NOT EXISTS sitters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + beschreibung TEXT, + preis_pro_tag REAL DEFAULT 0, + max_hunde INTEGER DEFAULT 1, + lat REAL, + lon REAL, + radius_km INTEGER DEFAULT 20, + services TEXT DEFAULT '[]', -- JSON: ['tagesbetreuung','uebernachtung','gassi','hausbesuch'] + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- SITTING — Anfragen + CREATE TABLE IF NOT EXISTS sitting_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), -- Anfragender + sitter_id INTEGER NOT NULL REFERENCES sitters(id), + dog_ids TEXT DEFAULT '[]', -- JSON Array + von TEXT NOT NULL, -- YYYY-MM-DD + bis TEXT NOT NULL, + nachricht TEXT, + status TEXT NOT NULL DEFAULT 'offen', -- offen|angenommen|abgelehnt|abgebrochen + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug) CREATE TABLE IF NOT EXISTS tieraerzte ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index d7dc7d6..13b6b3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,6 +60,8 @@ from routes.tieraerzte import router as tieraerzte_router from routes.places import router as places_router from routes.routen import router as routen_router from routes.walks import router as walks_router +from routes.events import router as events_router +from routes.sitting import router as sitting_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -72,6 +74,8 @@ app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzt app.include_router(places_router, prefix="/api/places", tags=["Orte"]) app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"]) +app.include_router(events_router, prefix="/api/events", tags=["Events"]) +app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py new file mode 100644 index 0000000..85e1811 --- /dev/null +++ b/backend/routes/events.py @@ -0,0 +1,153 @@ +"""BAN YARO — Events (Hundeveranstaltungen)""" + +import math +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} + + +def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class EventCreate(BaseModel): + titel: str + datum: str # YYYY-MM-DD + uhrzeit: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + ort_name: Optional[str] = None + typ: str = 'sonstiges' + beschreibung: Optional[str] = None + link: Optional[str] = None + +class EventUpdate(BaseModel): + titel: Optional[str] = None + datum: Optional[str] = None + uhrzeit: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + ort_name: Optional[str] = None + typ: Optional[str] = None + beschreibung: Optional[str] = None + link: Optional[str] = None + + +# ------------------------------------------------------------------ +# GET /api/events +# ------------------------------------------------------------------ +@router.get("") +async def list_events( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 50000, + typ: Optional[str] = None, + alle: bool = False, +): + today = date.today().isoformat() + with db() as conn: + q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'" + if not alle: + q += f" AND e.datum >= '{today}'" + if typ and typ in TYPEN: + q += f" AND e.typ = '{typ}'" + q += " ORDER BY e.datum ASC, e.uhrzeit ASC" + rows = conn.execute(q).fetchall() + + result = [dict(r) for r in rows] + if lat is not None and lon is not None: + result = [r for r in result + if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius] + return result + + +# ------------------------------------------------------------------ +# POST /api/events +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_event(data: EventCreate, user=Depends(get_current_user)): + if data.typ not in TYPEN: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}") + with db() as conn: + cur = conn.execute(""" + INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user['id'], data.titel, data.datum, data.uhrzeit, + data.lat, data.lon, data.ort_name, + data.typ, data.beschreibung, data.link)) + row = conn.execute( + "SELECT e.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/events/{id} +# ------------------------------------------------------------------ +@router.get("/{event_id}") +async def get_event(event_id: int): + with db() as conn: + row = conn.execute( + "SELECT e.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (event_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Event nicht gefunden.") + return dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/events/{id} +# ------------------------------------------------------------------ +@router.patch("/{event_id}") +async def update_event(event_id: int, data: EventUpdate, user=Depends(get_current_user)): + with db() as conn: + ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + if not ev: + raise HTTPException(404, "Event nicht gefunden.") + if ev['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.") + updates = data.model_dump(exclude_none=True) + if updates: + if 'typ' in updates and updates['typ'] not in TYPEN: + raise HTTPException(400, "Ungültiger Typ.") + 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.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (event_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/events/{id} +# ------------------------------------------------------------------ +@router.delete("/{event_id}", status_code=204) +async def delete_event(event_id: int, user=Depends(get_current_user)): + with db() as conn: + ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + if not ev: + raise HTTPException(404, "Event nicht gefunden.") + if 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,)) diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py new file mode 100644 index 0000000..acfa2e6 --- /dev/null +++ b/backend/routes/sitting.py @@ -0,0 +1,259 @@ +"""BAN YARO — Hundesitting""" + +import json +import math +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from database import db +from auth import get_current_user + +router = APIRouter() + +SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} + + +def _haversine(lat1, lon1, lat2, lon2): + R = 6_371_000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class SitterCreate(BaseModel): + beschreibung: Optional[str] = None + preis_pro_tag: float = 0 + max_hunde: int = 1 + lat: Optional[float] = None + lon: Optional[float] = None + radius_km: int = 20 + services: List[str] = [] + +class SitterUpdate(BaseModel): + beschreibung: Optional[str] = None + preis_pro_tag: Optional[float] = None + max_hunde: Optional[int] = None + lat: Optional[float] = None + lon: Optional[float] = None + radius_km: Optional[int] = None + services: Optional[List[str]] = None + aktiv: Optional[int] = None + +class RequestCreate(BaseModel): + sitter_id: int + dog_ids: List[int] = [] + von: str # YYYY-MM-DD + bis: str + nachricht: Optional[str] = None + +class RequestUpdate(BaseModel): + status: str # angenommen | abgelehnt | abgebrochen + + +# ------------------------------------------------------------------ +# GET /api/sitting — Sitter-Liste +# ------------------------------------------------------------------ +@router.get("") +async def list_sitters( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 30000, + service: Optional[str] = None, +): + with db() as conn: + rows = conn.execute(""" + SELECT s.*, u.name AS sitter_name + FROM sitters s + JOIN users u ON u.id = s.user_id + WHERE s.aktiv = 1 + """).fetchall() + + result = [] + for r in rows: + d = dict(r) + d['services'] = json.loads(d['services'] or '[]') + if service and service not in d['services']: + continue + if lat is not None and lon is not None and d['lat'] and d['lon']: + dist = _haversine(lat, lon, d['lat'], d['lon']) + if dist > radius: + continue + d['distanz_m'] = round(dist) + result.append(d) + + if lat is not None: + result.sort(key=lambda x: x.get('distanz_m', 999999)) + return result + + +# ------------------------------------------------------------------ +# GET /api/sitting/me — eigenes Sitter-Profil +# ------------------------------------------------------------------ +@router.get("/me") +async def get_my_sitter_profile(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not row: + return None + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# POST /api/sitting — Sitter-Profil erstellen oder aktualisieren +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_sitter(data: SitterCreate, user=Depends(get_current_user)): + services_json = json.dumps([s for s in data.services if s in SERVICES]) + with db() as conn: + existing = conn.execute( + "SELECT id FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if existing: + raise HTTPException(409, "Du hast bereits ein Sitter-Profil. Nutze PATCH zum Aktualisieren.") + cur = conn.execute(""" + INSERT INTO sitters (user_id, beschreibung, preis_pro_tag, max_hunde, lat, lon, radius_km, services) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (user['id'], data.beschreibung, data.preis_pro_tag, + data.max_hunde, data.lat, data.lon, data.radius_km, services_json)) + row = conn.execute("SELECT * FROM sitters WHERE id = ?", (cur.lastrowid,)).fetchone() + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# PATCH /api/sitting/me — eigenes Profil bearbeiten +# ------------------------------------------------------------------ +@router.patch("/me") +async def update_sitter(data: SitterUpdate, user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT * FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not sitter: + raise HTTPException(404, "Kein Sitter-Profil gefunden.") + updates = data.model_dump(exclude_none=True) + if 'services' in updates: + updates['services'] = json.dumps([s for s in updates['services'] if s in SERVICES]) + if updates: + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE sitters SET {cols} WHERE user_id = ?", + [*updates.values(), user['id']]) + row = conn.execute("SELECT * FROM sitters WHERE user_id = ?", (user['id'],)).fetchone() + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# GET /api/sitting/requests — meine Anfragen (als Anfragender) +# ------------------------------------------------------------------ +@router.get("/requests") +async def list_my_requests(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT sr.*, u.name AS sitter_name + FROM sitting_requests sr + JOIN sitters s ON s.id = sr.sitter_id + JOIN users u ON u.id = s.user_id + WHERE sr.user_id = ? + ORDER BY sr.created_at DESC + """, (user['id'],)).fetchall() + result = [] + for r in rows: + d = dict(r) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + result.append(d) + return result + + +# ------------------------------------------------------------------ +# GET /api/sitting/inbox — eingehende Anfragen (als Sitter) +# ------------------------------------------------------------------ +@router.get("/inbox") +async def sitter_inbox(user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT id FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not sitter: + return [] + rows = conn.execute(""" + SELECT sr.*, u.name AS anfragender_name + FROM sitting_requests sr + JOIN users u ON u.id = sr.user_id + WHERE sr.sitter_id = ? + ORDER BY sr.created_at DESC + """, (sitter['id'],)).fetchall() + result = [] + for r in rows: + d = dict(r) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + result.append(d) + return result + + +# ------------------------------------------------------------------ +# POST /api/sitting/requests — Anfrage senden +# ------------------------------------------------------------------ +@router.post("/requests", status_code=201) +async def create_request(data: RequestCreate, user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT * FROM sitters WHERE id = ?", (data.sitter_id,) + ).fetchone() + if not sitter or not sitter['aktiv']: + raise HTTPException(404, "Sitter nicht gefunden oder nicht aktiv.") + if sitter['user_id'] == user['id']: + raise HTTPException(400, "Du kannst keine Anfrage an dich selbst schicken.") + cur = conn.execute(""" + INSERT INTO sitting_requests (user_id, sitter_id, dog_ids, von, bis, nachricht) + VALUES (?, ?, ?, ?, ?, ?) + """, (user['id'], data.sitter_id, json.dumps(data.dog_ids), + data.von, data.bis, data.nachricht)) + row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (cur.lastrowid,)).fetchone() + d = dict(row) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + return d + + +# ------------------------------------------------------------------ +# PATCH /api/sitting/requests/{id} — Status ändern +# ------------------------------------------------------------------ +@router.patch("/requests/{req_id}") +async def update_request(req_id: int, data: RequestUpdate, user=Depends(get_current_user)): + allowed = {'angenommen', 'abgelehnt', 'abgebrochen'} + if data.status not in allowed: + raise HTTPException(400, f"Status muss einer von {allowed} sein.") + with db() as conn: + req = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + # Anfragender kann nur abbrechen; Sitter kann annehmen/ablehnen + sitter = conn.execute( + "SELECT * FROM sitters WHERE id = ?", (req['sitter_id'],) + ).fetchone() + is_requester = req['user_id'] == user['id'] + is_sitter = sitter['user_id'] == user['id'] + if not is_requester and not is_sitter: + raise HTTPException(403, "Kein Zugriff.") + if is_requester and data.status != 'abgebrochen': + raise HTTPException(403, "Du kannst die Anfrage nur abbrechen.") + if is_sitter and data.status == 'abgebrochen': + raise HTTPException(403, "Als Sitter kannst du nur annehmen oder ablehnen.") + conn.execute( + "UPDATE sitting_requests SET status = ? WHERE id = ?", (data.status, req_id) + ) + row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone() + d = dict(row) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + return d diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 123905b..31e84c7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1740,3 +1740,285 @@ textarea.form-control { .walks-participant:last-child { border-bottom: none; } .walks-participant-name { font-weight: var(--weight-semibold); } .walks-participant-dogs { color: var(--c-text-secondary); } + +/* ------------------------------------------------------------ + EVENTS (events.js) + ------------------------------------------------------------ */ +.events-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} +.events-view-toggle { + display: flex; + gap: var(--space-1); + background: var(--c-bg); + border-radius: var(--radius-md); + padding: 2px; + border: 1px solid var(--c-border); +} +.events-view-btn { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all 0.15s; +} +.events-view-btn.active { + background: var(--c-surface); + color: var(--c-text); + box-shadow: var(--shadow-xs); +} +.events-filter-bar { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + overflow-x: auto; + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; + scrollbar-width: none; +} +.events-filter-bar::-webkit-scrollbar { display: none; } +.events-filter-btn { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + border: 1.5px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + flex-shrink: 0; +} +.events-filter-btn.active { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); +} +.events-list { + flex: 1; + overflow-y: auto; + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.events-map { + flex: 1; + position: relative; +} +.events-month-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + padding: var(--space-2) 0 var(--space-1); +} +.events-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + border-left: 3px solid var(--c-primary); + padding: var(--space-3) var(--space-4); + display: grid; + grid-template-columns: 52px 1fr auto; + gap: var(--space-3); + cursor: pointer; + transition: box-shadow 0.15s; + align-items: center; + box-shadow: var(--shadow-xs); +} +.events-card:hover { box-shadow: var(--shadow-md); } +.events-date-badge { + display: flex; + flex-direction: column; + align-items: center; + background: var(--c-bg); + border-radius: var(--radius-md); + padding: var(--space-1); +} +.events-date-badge .day { font-size: var(--text-xs); color: var(--c-text-secondary); } +.events-date-badge .num { font-size: 1.5rem; font-weight: var(--weight-bold); line-height: 1.1; } +.events-date-badge .month { font-size: var(--text-xs); color: var(--c-text-secondary); } +.events-card-body { min-width: 0; } +.events-card-title { + font-weight: var(--weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: var(--space-1); +} +.events-card-meta { + font-size: var(--text-sm); + color: var(--c-text-secondary); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.events-badge { + display: inline-block; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} +.events-detail-row { + margin-bottom: var(--space-2); + color: var(--c-text); +} +.events-detail-desc { + background: var(--c-bg); + border-radius: var(--radius-md); + padding: var(--space-3); + margin: var(--space-3) 0; + color: var(--c-text-secondary); + white-space: pre-wrap; +} + +/* ------------------------------------------------------------ + SITTING (sitting.js) + ------------------------------------------------------------ */ +.sitting-tabs { + display: flex; + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); + flex-shrink: 0; +} +.sitting-tab { + flex: 1; + padding: var(--space-3); + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} +.sitting-tab.active { + color: var(--c-primary); + border-bottom-color: var(--c-primary); +} +.sitting-content { + flex: 1; + overflow-y: auto; + padding: var(--space-4); +} +.sitting-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.sitting-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + padding: var(--space-4); + display: grid; + grid-template-columns: 48px 1fr auto; + gap: var(--space-3); + align-items: flex-start; + cursor: pointer; + transition: box-shadow 0.15s; + box-shadow: var(--shadow-xs); +} +.sitting-card:hover { box-shadow: var(--shadow-md); } +.sitting-card-avatar { + width: 48px; + height: 48px; + background: var(--c-bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} +.sitting-card-name { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); } +.sitting-card-dist { font-size: var(--text-xs); color: var(--c-text-muted); margin-bottom: var(--space-1); } +.sitting-card-desc { font-size: var(--text-sm); color: var(--c-text-secondary); margin-bottom: var(--space-2); } +.sitting-price { font-weight: var(--weight-bold); color: var(--c-primary); } +.sitting-dogs { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: var(--space-1); } +.sitting-services { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} +.sit-service-badge { + background: var(--c-primary-light, #dbeafe); + color: var(--c-primary); + padding: 2px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-medium); +} +.sitting-empty-profil { + text-align: center; + padding: var(--space-8) var(--space-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} +.sitting-my-profil { display: flex; flex-direction: column; gap: var(--space-3); } +.sitting-profil-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.sitting-profil-status { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); +} +.sitting-profil-status.active { background: #d1fae5; color: #065f46; } +.sitting-profil-status.inactive { background: #f3f4f6; color: #6b7280; } +.sitting-profil-facts { + display: flex; + gap: var(--space-4); +} +.sitting-profil-fact { font-size: var(--text-sm); color: var(--c-text-secondary); } +.sitting-section-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + margin-bottom: var(--space-2); +} +.sitting-request-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-2); + box-shadow: var(--shadow-xs); +} +.sitting-req-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-1); +} +.sitting-req-name { font-weight: var(--weight-semibold); } +.sitting-req-status { font-size: var(--text-sm); font-weight: var(--weight-semibold); text-transform: capitalize; } +.sitting-req-dates { font-size: var(--text-sm); color: var(--c-text-secondary); } +.sitting-req-msg { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); } +.sitting-req-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); +} +.sitting-detail-avatar { + font-size: 4rem; + text-align: center; + margin-bottom: var(--space-2); +} diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 705b826..0809ac6 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -207,6 +207,39 @@ const API = (() => { leave(id) { return del(`/walks/${id}/join`); }, }; + // ---------------------------------------------------------- + // EVENTS + // ---------------------------------------------------------- + const events = { + list(params = {}) { + 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}`); }, + }; + + // ---------------------------------------------------------- + // SITTING + // ---------------------------------------------------------- + const sitting = { + list(lat = null, lon = null, radius = 30000, service = null) { + const p = new URLSearchParams({ radius }); + if (lat !== null) { p.set('lat', lat); p.set('lon', lon); } + if (service) { p.set('service', service); } + return get(`/sitting?${p}`); + }, + me() { return get('/sitting/me'); }, + create(data) { return post('/sitting', data); }, + updateMe(data) { return patch('/sitting/me', data); }, + requests() { return get('/sitting/requests'); }, + inbox() { return get('/sitting/inbox'); }, + sendRequest(data){ return post('/sitting/requests', data); }, + updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); }, + }; + // ---------------------------------------------------------- // WETTER // ---------------------------------------------------------- @@ -284,7 +317,7 @@ const API = (() => { return { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, - places, routes, walks, weather, push, + places, routes, walks, events, sitting, weather, push, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js new file mode 100644 index 0000000..3fb6bd5 --- /dev/null +++ b/backend/static/js/pages/events.js @@ -0,0 +1,419 @@ +/* ============================================================ + BAN YARO — Events (Hundeveranstaltungen) + Liste/Karte · Filter · Erstellen/Bearbeiten + ============================================================ */ + +const Page_events = (() => { + + // ---------------------------------------------------------- + // Konstanten + // ---------------------------------------------------------- + const TYPEN = [ + { id: 'alle', label: 'Alle', icon: '🎪' }, + { id: 'ausstellung', label: 'Ausstellung', icon: '🏆' }, + { id: 'training', label: 'Training', icon: '🎓' }, + { id: 'treffen', label: 'Treffen', icon: '🐕' }, + { id: 'markt', label: 'Markt', icon: '🛍️' }, + { id: 'wettkampf', label: 'Wettkampf', icon: '🥇' }, + { id: 'sonstiges', label: 'Sonstiges', icon: '📌' }, + ]; + + const TYP_COLOR = { + ausstellung: '#8b5cf6', + training: '#3b82f6', + treffen: '#10b981', + markt: '#f59e0b', + wettkampf: '#ef4444', + sonstiges: '#6b7280', + }; + + // ---------------------------------------------------------- + // State + // ---------------------------------------------------------- + let _container = null; + let _state = null; + let _events = []; + let _filter = 'alle'; + let _view = 'liste'; // liste | karte + let _map = null; + let _markers = []; + + // ---------------------------------------------------------- + // init + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _state = appState; + _render(); + await _load(); + } + + async function refresh() { + await _load(); + } + + // ---------------------------------------------------------- + // Render Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+
+ + +
+
+ ${_state.user ? `` : ''} +
+ +
+ ${TYPEN.map(t => ` + + `).join('')} +
+ +
+ + `; + + _container.addEventListener('click', _onClick); + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _load() { + const listEl = document.getElementById('ev-list'); + if (!listEl) return; + listEl.innerHTML = UI.skeleton(3); + try { + _events = await API.events.list(); + _renderList(); + } catch (e) { + UI.toast(e.message, 'error'); + } + } + + // ---------------------------------------------------------- + // Liste rendern + // ---------------------------------------------------------- + function _renderList() { + const listEl = document.getElementById('ev-list'); + if (!listEl) return; + + const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter); + if (!filtered.length) { + listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' }); + return; + } + + // Monats-Gruppierung + const groups = {}; + for (const ev of filtered) { + const d = new Date(ev.datum + 'T00:00:00'); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + if (!groups[key]) groups[key] = { label, items: [] }; + groups[key].items.push(ev); + } + + let html = ''; + for (const key of Object.keys(groups).sort()) { + const g = groups[key]; + html += `
${g.label}
`; + for (const ev of g.items) { + html += _cardHTML(ev); + } + } + listEl.innerHTML = html; + + if (_view === 'karte') _renderMap(filtered); + } + + function _cardHTML(ev) { + const d = new Date(ev.datum + 'T00:00:00'); + const dow = d.toLocaleDateString('de-DE', { weekday: 'short' }); + const day = d.getDate(); + const mon = d.toLocaleDateString('de-DE', { month: 'short' }); + const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; + const color = TYP_COLOR[ev.typ] || '#6b7280'; + const isOwn = _state.user?.id === ev.user_id; + return ` +
+
+ ${dow} + ${day} + ${mon} +
+
+
${UI.escHtml(ev.titel)}
+
+ ${typ.icon} ${typ.label} + ${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''} + ${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''} +
+
+ ${isOwn ? `` : ''} +
+ `; + } + + // ---------------------------------------------------------- + // Karte + // ---------------------------------------------------------- + async function _renderMap(filtered) { + const mapEl = document.getElementById('ev-map'); + if (!mapEl) return; + await _loadLeaflet(); + if (!_map) { + _map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); + } else { + _markers.forEach(m => m.remove()); + _markers = []; + } + + const bounds = []; + for (const ev of filtered) { + if (!ev.lat || !ev.lon) continue; + const color = TYP_COLOR[ev.typ] || '#6b7280'; + const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1]; + const icon = L.divIcon({ + className: '', + html: `
${typ.icon}
`, + iconSize: [32, 32], iconAnchor: [16, 32], + }); + const m = L.marker([ev.lat, ev.lon], { icon }) + .addTo(_map) + .on('click', () => _showDetail(ev.id)); + _markers.push(m); + bounds.push([ev.lat, ev.lon]); + } + + if (bounds.length) _map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 }); + setTimeout(() => _map.invalidateSize(), 50); + } + + function _loadLeaflet() { + if (window.L) return Promise.resolve(); + return new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/js/lib/leaflet.css'; + document.head.appendChild(link); + const s = document.createElement('script'); + s.src = '/js/lib/leaflet.js'; + s.onload = resolve; + s.onerror = reject; + document.head.appendChild(s); + }); + } + + // ---------------------------------------------------------- + // Detail-Modal + // ---------------------------------------------------------- + async function _showDetail(id) { + 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 body = ` +
+ ${typ.icon} ${typ.label} +
+
📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}
+ ${ev.ort_name ? `
📍 ${UI.escHtml(ev.ort_name)}
` : ''} + ${ev.beschreibung ? `
${UI.escHtml(ev.beschreibung)}
` : ''} + ${ev.link ? `
🔗 Mehr Infos
` : ''} +
Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
+ `; + + const footer = isOwn ? ` + + + ` : ''; + + UI.modal.open({ title: UI.escHtml(ev.titel), body, footer }); + + document.getElementById('ev-detail-edit')?.addEventListener('click', () => { + UI.modal.close(); setTimeout(() => _openForm(ev), 50); + }); + document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev)); + } + + async function _deleteEvent(ev) { + if (!confirm(`"${ev.titel}" wirklich löschen?`)) return; + try { + await API.events.delete(ev.id); + UI.modal.close(); + UI.toast('Event gelöscht.'); + await _load(); + } catch (e) { UI.toast(e.message, 'error'); } + } + + // ---------------------------------------------------------- + // Erstellen / Bearbeiten + // ---------------------------------------------------------- + function openNew() { _openForm(null); } + + function _openForm(ev) { + const isEdit = !!ev; + const id = 'ev-form'; + const body = ` +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ `; + + const footer = ` + + + `; + + UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer }); + + document.getElementById('ev-gps-btn')?.addEventListener('click', async () => { + try { + const pos = await API.getLocation(); + document.getElementById('ev-lat').value = pos.lat.toFixed(6); + document.getElementById('ev-lon').value = pos.lon.toFixed(6); + } catch { UI.toast('GPS nicht verfügbar.', 'error'); } + }); + + const form = document.getElementById(id); + const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]'); + + form.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(form); + const data = { + titel: fd.get('titel'), + datum: fd.get('datum'), + uhrzeit: fd.get('uhrzeit') || null, + typ: fd.get('typ'), + ort_name: fd.get('ort_name') || null, + lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null, + lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null, + beschreibung: fd.get('beschreibung') || null, + link: fd.get('link') || null, + }; + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + isEdit ? await API.events.update(ev.id, data) : await API.events.create(data); + UI.modal.close(); + UI.toast(isEdit ? 'Event aktualisiert.' : 'Event erstellt!'); + await _load(); + } catch (err) { + UI.toast(err.message, 'error'); + } finally { + if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Speichern' : 'Event erstellen'; } + } + }); + } + + // ---------------------------------------------------------- + // Click-Handler + // ---------------------------------------------------------- + function _onClick(e) { + // Filter + const filterBtn = e.target.closest('[data-ev-typ]'); + if (filterBtn) { + _filter = filterBtn.dataset.evTyp; + document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter)); + _renderList(); + return; + } + + // View-Toggle + const viewBtn = e.target.closest('[data-ev-view]'); + if (viewBtn) { + _view = viewBtn.dataset.evView; + document.querySelectorAll('[data-ev-view]').forEach(b => b.classList.toggle('active', b.dataset.evView === _view)); + const listEl = document.getElementById('ev-list'); + const mapEl = document.getElementById('ev-map'); + if (_view === 'karte') { + listEl.style.display = 'none'; + mapEl.style.display = 'block'; + const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter); + _renderMap(filtered); + } else { + listEl.style.display = ''; + mapEl.style.display = 'none'; + } + return; + } + + // Neu-Button + if (e.target.closest('#ev-new-btn')) { openNew(); return; } + + // Bearbeiten-Icon auf Karte + const editBtn = e.target.closest('[data-ev-edit]'); + if (editBtn) { + e.stopPropagation(); + const id = parseInt(editBtn.dataset.evEdit); + const ev = _events.find(x => x.id === id); + if (ev) _openForm(ev); + return; + } + + // Karten-Klick → Detail + const card = e.target.closest('[data-ev-id]'); + if (card) { _showDetail(parseInt(card.dataset.evId)); } + } + + return { init, refresh, openNew }; + +})(); diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js new file mode 100644 index 0000000..f77dfab --- /dev/null +++ b/backend/static/js/pages/sitting.js @@ -0,0 +1,482 @@ +/* ============================================================ + BAN YARO — Hundesitting + Sitter suchen · Profil anbieten · Anfragen verwalten + ============================================================ */ + +const Page_sitting = (() => { + + // ---------------------------------------------------------- + // Konstanten + // ---------------------------------------------------------- + const SERVICES = [ + { id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '☀️' }, + { id: 'uebernachtung', label: 'Übernachtung', icon: '🌙' }, + { id: 'gassi', label: 'Gassi gehen', icon: '🦮' }, + { id: 'hausbesuch', label: 'Hausbesuch', icon: '🏠' }, + ]; + + // ---------------------------------------------------------- + // State + // ---------------------------------------------------------- + let _container = null; + let _state = null; + let _tab = 'suchen'; // suchen | profil | anfragen + let _sitters = []; + let _mySitter = null; + let _myRequests = []; + let _inbox = []; + + // ---------------------------------------------------------- + // init + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _state = appState; + _render(); + await _load(); + } + + async function refresh() { await _load(); } + + // ---------------------------------------------------------- + // Render Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + ${_state.user ? ` + + + ` : ''} +
+
+ `; + _container.addEventListener('click', _onClick); + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _load() { + const content = document.getElementById('sit-content'); + if (!content) return; + content.innerHTML = UI.skeleton(3); + + const tasks = [API.sitting.list()]; + if (_state.user) { + tasks.push(API.sitting.me()); + tasks.push(API.sitting.requests()); + tasks.push(API.sitting.inbox()); + } + + try { + const results = await Promise.allSettled(tasks); + _sitters = results[0].status === 'fulfilled' ? results[0].value : []; + _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 : []; + } catch {} + + _renderTab(); + } + + // ---------------------------------------------------------- + // Tab-Inhalte + // ---------------------------------------------------------- + function _renderTab() { + const content = document.getElementById('sit-content'); + if (!content) return; + if (_tab === 'suchen') _renderSuchen(content); + if (_tab === 'profil') _renderProfil(content); + if (_tab === 'anfragen') _renderAnfragen(content); + } + + // ---- Tab: Sitter suchen ---- + function _renderSuchen(el) { + if (!_sitters.length) { + el.innerHTML = UI.emptyState({ icon: '🐕', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' }); + return; + } + el.innerHTML = ` +
+ ${_sitters.map(s => _sitterCardHTML(s)).join('')} +
+ `; + } + + function _sitterCardHTML(s) { + const svcs = (s.services || []).map(id => { + const svc = SERVICES.find(x => x.id === id); + return svc ? `${svc.icon} ${svc.label}` : ''; + }).join(''); + const dist = s.distanz_m != null + ? (s.distanz_m >= 1000 ? (s.distanz_m / 1000).toFixed(1) + ' km' : s.distanz_m + ' m') + : ''; + return ` +
+
🐾
+
+
${UI.escHtml(s.sitter_name)}
+ ${dist ? `
📍 ${dist} entfernt
` : ''} + ${s.beschreibung ? `
${UI.escHtml(s.beschreibung)}
` : ''} +
${svcs}
+
+
+
${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}
+
max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}
+
+
+ `; + } + + // ---- Tab: Mein Profil ---- + function _renderProfil(el) { + if (!_mySitter) { + el.innerHTML = ` +
+
🐾
+

Werde Hundesitter

+

Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.

+ +
+ `; + return; + } + const s = _mySitter; + const svcs = (s.services || []).map(id => SERVICES.find(x => x.id === id)?.label || id).join(', '); + el.innerHTML = ` +
+
+
+ ${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'} +
+ +
+ ${s.beschreibung ? `

${UI.escHtml(s.beschreibung)}

` : ''} +
+
${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : '–'} pro Tag
+
${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''} max.
+
${s.radius_km} km Umkreis
+
+
${svcs || '(keine Services angegeben)'}
+
+ `; + } + + // ---- Tab: Anfragen ---- + function _renderAnfragen(el) { + const inbox = _inbox; + const myReqs = _myRequests; + + let html = ''; + + if (inbox.length) { + html += `
📬 Eingehende Anfragen (als Sitter)
`; + html += inbox.map(r => _requestCardHTML(r, 'inbox')).join(''); + } + + if (myReqs.length) { + html += `
📤 Meine Anfragen
`; + html += myReqs.map(r => _requestCardHTML(r, 'sent')).join(''); + } + + if (!inbox.length && !myReqs.length) { + html = UI.emptyState({ icon: '📬', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' }); + } + + el.innerHTML = html; + } + + function _requestCardHTML(r, mode) { + const STATUS_COLOR = { offen: '#f59e0b', angenommen: '#10b981', abgelehnt: '#ef4444', abgebrochen: '#6b7280' }; + const color = STATUS_COLOR[r.status] || '#6b7280'; + const name = mode === 'inbox' ? r.anfragender_name : r.sitter_name; + return ` +
+
+ ${UI.escHtml(name || '?')} + ${r.status} +
+
📅 ${r.von} – ${r.bis}
+ ${r.nachricht ? `
${UI.escHtml(r.nachricht)}
` : ''} + ${r.status === 'offen' ? _requestActions(r.id, mode) : ''} +
+ `; + } + + function _requestActions(id, mode) { + if (mode === 'inbox') { + return ` +
+ + +
+ `; + } + return ` +
+ +
+ `; + } + + // ---------------------------------------------------------- + // Sitter Detail + Anfrage senden + // ---------------------------------------------------------- + function _showSitterDetail(id) { + const s = _sitters.find(x => x.id === id); + if (!s) return; + const svcs = (s.services || []).map(sv => { + const f = SERVICES.find(x => x.id === sv); + return f ? `${f.icon} ${f.label}` : ''; + }).join(''); + const dist = s.distanz_m != null + ? (s.distanz_m >= 1000 ? (s.distanz_m / 1000).toFixed(1) + ' km' : s.distanz_m + ' m') + : null; + + const body = ` +
🐾
+

${UI.escHtml(s.sitter_name)}

+ ${dist ? `
📍 ${dist} entfernt
` : ''} + ${s.beschreibung ? `

${UI.escHtml(s.beschreibung)}

` : ''} +
${svcs}
+
+
${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : '–'} pro Tag
+
${s.max_hunde} max. Hund${s.max_hunde !== 1 ? 'e' : ''}
+
${s.radius_km} km Umkreis
+
+ `; + + const footer = _state.user && _mySitter?.user_id !== s.user_id ? ` + + ` : (!_state.user ? `Zum Anfragen bitte einloggen.` : ''); + + UI.modal.open({ title: 'Sitter-Profil', body, footer }); + + document.getElementById('sit-anfrage-btn')?.addEventListener('click', () => { + UI.modal.close(); + setTimeout(() => _openAnfrageForm(s), 50); + }); + } + + function _openAnfrageForm(s) { + const dogs = _state.dogs || []; + const id = 'sit-anfrage-form'; + const body = ` +
+

Anfrage an ${UI.escHtml(s.sitter_name)}

+
+
+ + +
+
+ + +
+
+ ${dogs.length ? ` +
+ + ${dogs.map(d => ` + + `).join('')} +
+ ` : ''} +
+ + +
+
+ `; + const footer = ` + + + `; + UI.modal.open({ title: 'Anfrage senden', body, footer }); + + const form = document.getElementById(id); + const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]'); + + form.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(form); + const dogIds = [...form.querySelectorAll('[name="dog_ids"]:checked')].map(cb => parseInt(cb.value)); + const data = { + sitter_id: s.id, + von: fd.get('von'), + bis: fd.get('bis'), + dog_ids: dogIds, + nachricht: fd.get('nachricht') || null, + }; + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + await API.sitting.sendRequest(data); + UI.modal.close(); + UI.toast('Anfrage gesendet!'); + await _load(); + } catch (err) { + UI.toast(err.message, 'error'); + } finally { + if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Anfrage senden'; } + } + }); + } + + // ---------------------------------------------------------- + // Sitter-Profil Formular + // ---------------------------------------------------------- + function _openProfilForm() { + const s = _mySitter; + const id = 'sit-profil-form'; + const body = ` +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + ${SERVICES.map(svc => ` + + `).join('')} +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ ${s ? ` +
+ +
+ ` : ''} +
+ `; + const footer = ` + + + `; + UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer }); + + document.getElementById('sit-gps-btn')?.addEventListener('click', async () => { + try { + const pos = await API.getLocation(); + document.getElementById('sit-lat').value = pos.lat.toFixed(6); + document.getElementById('sit-lon').value = pos.lon.toFixed(6); + } catch { UI.toast('GPS nicht verfügbar.', 'error'); } + }); + + const form = document.getElementById(id); + const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]'); + + form.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(form); + const svcs = [...form.querySelectorAll('[name="services"]:checked')].map(cb => cb.value); + const data = { + beschreibung: fd.get('beschreibung') || null, + preis_pro_tag: parseFloat(fd.get('preis_pro_tag')) || 0, + max_hunde: parseInt(fd.get('max_hunde')) || 1, + services: svcs, + lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null, + lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null, + radius_km: parseInt(fd.get('radius_km')) || 20, + }; + if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0; + + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + if (s) { + await API.sitting.updateMe(data); + } else { + await API.sitting.create(data); + } + UI.modal.close(); + UI.toast(s ? 'Profil aktualisiert.' : 'Profil erstellt!'); + await _load(); + } catch (err) { + UI.toast(err.message, 'error'); + } finally { + if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = s ? 'Speichern' : 'Profil erstellen'; } + } + }); + } + + // ---------------------------------------------------------- + // Click-Handler + // ---------------------------------------------------------- + function _onClick(e) { + // Tab-Wechsel + const tabBtn = e.target.closest('[data-sit-tab]'); + if (tabBtn) { + _tab = tabBtn.dataset.sitTab; + document.querySelectorAll('[data-sit-tab]').forEach(b => b.classList.toggle('active', b.dataset.sitTab === _tab)); + _renderTab(); + return; + } + + // Sitter-Karte + const sitterCard = e.target.closest('[data-sit-id]'); + if (sitterCard && !e.target.closest('button')) { + _showSitterDetail(parseInt(sitterCard.dataset.sitId)); + return; + } + + // Profil erstellen + if (e.target.closest('#sit-create-profil-btn') || e.target.closest('#sit-edit-profil-btn')) { + _openProfilForm(); + return; + } + + // Anfragen-Aktionen + const acceptBtn = e.target.closest('[data-sit-accept]'); + const declineBtn = e.target.closest('[data-sit-decline]'); + const cancelBtn = e.target.closest('[data-sit-cancel]'); + if (acceptBtn) { _changeReqStatus(parseInt(acceptBtn.dataset.sitAccept), 'angenommen'); return; } + if (declineBtn) { _changeReqStatus(parseInt(declineBtn.dataset.sitDecline), 'abgelehnt'); return; } + if (cancelBtn) { _changeReqStatus(parseInt(cancelBtn.dataset.sitCancel), 'abgebrochen'); return; } + } + + async function _changeReqStatus(id, status) { + try { + await API.sitting.updateRequest(id, status); + UI.toast(`Anfrage ${status}.`); + await _load(); + } catch (e) { UI.toast(e.message, 'error'); } + } + + return { init, refresh }; + +})(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 7c891be..8a80f1d 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications ============================================================ */ -const CACHE_VERSION = 'by-v21'; +const CACHE_VERSION = 'by-v22'; const CACHE_STATIC = `${CACHE_VERSION}-static`; // Diese Dateien werden beim Install gecacht (App Shell)