Sprint 8: Events + Hundesitting

Events:
- Backend events.py: CRUD, Typen (ausstellung/training/treffen/markt/wettkampf/sonstiges)
  Haversine-Filter, Monats-Gruppierung in der Liste
- Frontend events.js: Liste/Karte-Toggle, Typ-Filter-Chips, farbige Marker,
  Detail-Modal, Erstellen/Bearbeiten-Formular mit GPS-Button

Hundesitting:
- Backend sitting.py: Sitter-Profile (create/update/me), Anfragen (send/accept/decline/cancel),
  Inbox für Sitter, Haversine-Sortierung, Service-Filter
- Frontend sitting.js: 3 Tabs (Suchen/Profil/Anfragen), Sitter-Karten mit Distanz,
  Detail-Modal + Anfrage-Formular, Profil-Verwaltung

DB: events, sitters, sitting_requests Tabellen hinzugefügt
SW-Cache: by-v21 → by-v22
This commit is contained in:
rene 2026-04-14 06:19:15 +02:00
parent ec17dfb029
commit 5f8fd3bd51
9 changed files with 1680 additions and 2 deletions

View file

@ -249,6 +249,52 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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) -- TIERÄRZTE (user-level, nie löschen Historien-Erhalt bei Umzug)
CREATE TABLE IF NOT EXISTS tieraerzte ( CREATE TABLE IF NOT EXISTS tieraerzte (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -60,6 +60,8 @@ from routes.tieraerzte import router as tieraerzte_router
from routes.places import router as places_router from routes.places import router as places_router
from routes.routen import router as routen_router from routes.routen import router as routen_router
from routes.walks import router as walks_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(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"])
@ -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(places_router, prefix="/api/places", tags=["Orte"])
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"]) 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"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

153
backend/routes/events.py Normal file
View file

@ -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,))

259
backend/routes/sitting.py Normal file
View file

@ -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

View file

@ -1740,3 +1740,285 @@ textarea.form-control {
.walks-participant:last-child { border-bottom: none; } .walks-participant:last-child { border-bottom: none; }
.walks-participant-name { font-weight: var(--weight-semibold); } .walks-participant-name { font-weight: var(--weight-semibold); }
.walks-participant-dogs { color: var(--c-text-secondary); } .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);
}

View file

@ -207,6 +207,39 @@ const API = (() => {
leave(id) { return del(`/walks/${id}/join`); }, 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 // WETTER
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -284,7 +317,7 @@ const API = (() => {
return { return {
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, weather, push, places, routes, walks, events, sitting, weather, push,
subscribeToPush, getLocation, subscribeToPush, getLocation,
APIError, APIError,
}; };

View file

@ -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 = `
<div class="events-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste"> Liste</button>
<button class="events-view-btn" data-ev-view="karte">🗺 Karte</button>
</div>
<div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
</div>
<div class="events-filter-bar" id="ev-filter-bar">
${TYPEN.map(t => `
<button class="events-filter-btn ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
${t.icon} ${t.label}
</button>
`).join('')}
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" style="display:none"></div>
`;
_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 += `<div class="events-month-label">${g.label}</div>`;
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 `
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
<div class="events-date-badge">
<span class="day">${dow}</span>
<span class="num">${day}</span>
<span class="month">${mon}</span>
</div>
<div class="events-card-body">
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
</div>
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// 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: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg)"><span style="transform:rotate(45deg)">${typ.icon}</span></div>`,
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 = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
</div>
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}</div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit"> Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">🗑 Löschen</button>
` : '';
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 = `
<form id="${id}">
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" name="titel" required value="${ev ? UI.escHtml(ev.titel) : ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${ev?.datum || ''}">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${ev?.uhrzeit || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Typ *</label>
<select class="form-control" name="typ">
${TYPEN.filter(t => t.id !== 'alle').map(t =>
`<option value="${t.id}" ${ev?.typ === t.id ? 'selected' : ''}>${t.icon} ${t.label}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Ort / Veranstaltungsort</label>
<input class="form-control" name="ort_name" placeholder="z.B. Stadtpark München" value="${ev?.ort_name || ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Breitengrad</label>
<input class="form-control" type="number" step="any" name="lat" id="ev-lat" placeholder="48.1234" value="${ev?.lat || ''}">
</div>
<div class="form-group">
<label class="form-label">Längengrad</label>
<input class="form-control" type="number" step="any" name="lon" id="ev-lon" placeholder="11.5678" value="${ev?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">📍 GPS-Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div>
<div class="form-group">
<label class="form-label">Link / Website</label>
<input class="form-control" type="url" name="link" placeholder="https://..." value="${ev?.link || ''}">
</div>
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn">
${isEdit ? 'Speichern' : 'Event erstellen'}
</button>
`;
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 };
})();

View file

@ -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 = `
<div class="sitting-tabs" id="sit-tabs">
<button class="sitting-tab active" data-sit-tab="suchen">🔍 Sitter finden</button>
${_state.user ? `
<button class="sitting-tab" data-sit-tab="profil">👤 Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">📬 Anfragen</button>
` : ''}
</div>
<div id="sit-content" class="sitting-content"></div>
`;
_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 = `
<div class="sitting-list">
${_sitters.map(s => _sitterCardHTML(s)).join('')}
</div>
`;
}
function _sitterCardHTML(s) {
const svcs = (s.services || []).map(id => {
const svc = SERVICES.find(x => x.id === id);
return svc ? `<span class="sit-service-badge">${svc.icon} ${svc.label}</span>` : '';
}).join('');
const dist = s.distanz_m != null
? (s.distanz_m >= 1000 ? (s.distanz_m / 1000).toFixed(1) + ' km' : s.distanz_m + ' m')
: '';
return `
<div class="sitting-card" data-sit-id="${s.id}">
<div class="sitting-card-avatar">🐾</div>
<div class="sitting-card-body">
<div class="sitting-card-name">${UI.escHtml(s.sitter_name)}</div>
${dist ? `<div class="sitting-card-dist">📍 ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<div class="sitting-card-desc">${UI.escHtml(s.beschreibung)}</div>` : ''}
<div class="sitting-services">${svcs}</div>
</div>
<div class="sitting-card-side">
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
</div>
</div>
`;
}
// ---- Tab: Mein Profil ----
function _renderProfil(el) {
if (!_mySitter) {
el.innerHTML = `
<div class="sitting-empty-profil">
<div style="font-size:3rem">🐾</div>
<h3>Werde Hundesitter</h3>
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
</div>
`;
return;
}
const s = _mySitter;
const svcs = (s.services || []).map(id => SERVICES.find(x => x.id === id)?.label || id).join(', ');
el.innerHTML = `
<div class="sitting-my-profil">
<div class="sitting-profil-header">
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'}
</div>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn"> Bearbeiten</button>
</div>
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-profil-facts">
<div class="sitting-profil-fact"><strong>${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : ''}</strong> pro Tag</div>
<div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> Hund${s.max_hunde !== 1 ? 'e' : ''} max.</div>
<div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div>
</div>
<div class="sitting-services" style="margin-top:var(--space-3)">${svcs || '(keine Services angegeben)'}</div>
</div>
`;
}
// ---- Tab: Anfragen ----
function _renderAnfragen(el) {
const inbox = _inbox;
const myReqs = _myRequests;
let html = '';
if (inbox.length) {
html += `<div class="sitting-section-label">📬 Eingehende Anfragen (als Sitter)</div>`;
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
}
if (myReqs.length) {
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">📤 Meine Anfragen</div>`;
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 `
<div class="sitting-request-card" data-sit-req="${r.id}" data-sit-req-mode="${mode}">
<div class="sitting-req-header">
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
</div>
<div class="sitting-req-dates">📅 ${r.von} ${r.bis}</div>
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
${r.status === 'offen' ? _requestActions(r.id, mode) : ''}
</div>
`;
}
function _requestActions(id, mode) {
if (mode === 'inbox') {
return `
<div class="sitting-req-actions">
<button class="btn btn-primary btn-sm" data-sit-accept="${id}"> Annehmen</button>
<button class="btn btn-danger btn-sm" data-sit-decline="${id}"> Ablehnen</button>
</div>
`;
}
return `
<div class="sitting-req-actions">
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">🚫 Abbrechen</button>
</div>
`;
}
// ----------------------------------------------------------
// 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 ? `<span class="sit-service-badge">${f.icon} ${f.label}</span>` : '';
}).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 = `
<div class="sitting-detail-avatar">🐾</div>
<h3 style="margin:var(--space-2) 0">${UI.escHtml(s.sitter_name)}</h3>
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-services" style="margin:var(--space-3) 0">${svcs}</div>
<div class="sitting-profil-facts">
<div class="sitting-profil-fact"><strong>${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €' : ''}</strong> pro Tag</div>
<div class="sitting-profil-fact"><strong>${s.max_hunde}</strong> max. Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
<div class="sitting-profil-fact"><strong>${s.radius_km} km</strong> Umkreis</div>
</div>
`;
const footer = _state.user && _mySitter?.user_id !== s.user_id ? `
<button class="btn btn-primary" id="sit-anfrage-btn">📬 Anfrage senden</button>
` : (!_state.user ? `<span style="color:var(--c-text-secondary)">Zum Anfragen bitte einloggen.</span>` : '');
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 = `
<form id="${id}">
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">Anfrage an <strong>${UI.escHtml(s.sitter_name)}</strong></p>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Von *</label>
<input class="form-control" type="date" name="von" required>
</div>
<div class="form-group">
<label class="form-label">Bis *</label>
<input class="form-control" type="date" name="bis" required>
</div>
</div>
${dogs.length ? `
<div class="form-group">
<label class="form-label">Welche Hunde?</label>
${dogs.map(d => `
<label class="form-check">
<input type="checkbox" name="dog_ids" value="${d.id}" checked>
${UI.escHtml(d.name)}
</label>
`).join('')}
</div>
` : ''}
<div class="form-group">
<label class="form-label">Nachricht</label>
<textarea class="form-control" name="nachricht" rows="3" placeholder="z.B. Besonderheiten deines Hundes…"></textarea>
</div>
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-anfrage-submit">Anfrage senden</button>
`;
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 = `
<form id="${id}">
<div class="form-group">
<label class="form-label">Über mich / Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${s?.beschreibung || ''}</textarea>
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Preis pro Tag ()</label>
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag" value="${s?.preis_pro_tag ?? 0}">
</div>
<div class="form-group">
<label class="form-label">Max. Hunde</label>
<input class="form-control" type="number" min="1" max="10" name="max_hunde" value="${s?.max_hunde ?? 1}">
</div>
</div>
<div class="form-group">
<label class="form-label">Services</label>
${SERVICES.map(svc => `
<label class="form-check">
<input type="checkbox" name="services" value="${svc.id}" ${s?.services?.includes(svc.id) ? 'checked' : ''}>
${svc.icon} ${svc.label}
</label>
`).join('')}
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Breitengrad</label>
<input class="form-control" type="number" step="any" name="lat" id="sit-lat" value="${s?.lat || ''}">
</div>
<div class="form-group">
<label class="form-label">Längengrad</label>
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">📍 Meine Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">
</div>
${s ? `
<div class="form-group">
<label class="form-check">
<input type="checkbox" name="aktiv" value="1" ${s.aktiv ? 'checked' : ''}> Profil aktiv (sichtbar für andere)
</label>
</div>
` : ''}
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit">
${s ? 'Speichern' : 'Profil erstellen'}
</button>
`;
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 };
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications Offline-Cache + Push Notifications
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v21'; const CACHE_VERSION = 'by-v22';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
// Diese Dateien werden beim Install gecacht (App Shell) // Diese Dateien werden beim Install gecacht (App Shell)