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

@ -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 };
})();