Sprint 19: Social, UX-Verbesserungen, Nerd2Noob-Hilfe
This commit is contained in:
parent
10d30bf565
commit
89d87030a2
18 changed files with 930 additions and 74 deletions
|
|
@ -5044,3 +5044,93 @@ textarea.form-control {
|
|||
color: var(--c-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* health chart extras (weight chart) */
|
||||
.health-chart-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); margin-bottom: var(--space-2); }
|
||||
.health-chart-svg { width: 100%; height: auto; display: block; }
|
||||
.health-chart-labels { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: var(--space-1); }
|
||||
.health-chart-empty { font-size: var(--text-sm); color: var(--c-text-muted); text-align: center; padding: var(--space-4) 0; }
|
||||
|
||||
/* ============================================================
|
||||
RSVP — Event-Teilnahme
|
||||
============================================================ */
|
||||
.event-rsvp-bar { display:flex; gap:var(--space-2); align-items:center; margin:var(--space-3) 0 var(--space-2); flex-wrap:wrap; }
|
||||
.event-rsvp-btn { display:inline-flex; align-items:center; gap:var(--space-1); padding:6px 14px; border-radius:var(--radius); border:1.5px solid var(--c-border); background:var(--c-surface); color:var(--c-text-secondary); font-size:var(--text-sm); font-weight:500; cursor:pointer; transition:background .15s,color .15s,border-color .15s; }
|
||||
.event-rsvp-btn:hover { border-color:var(--c-primary); color:var(--c-primary); }
|
||||
.event-rsvp-btn.active { background:var(--c-primary); border-color:var(--c-primary); color:#fff; }
|
||||
.event-attendees { font-size:var(--text-sm); color:var(--c-text-secondary); cursor:pointer; display:inline-flex; align-items:center; gap:var(--space-1); }
|
||||
.event-attendees:hover { color:var(--c-primary); }
|
||||
.ev-attendees-list { display:flex; flex-wrap:wrap; gap:var(--space-1); margin-top:var(--space-2); }
|
||||
.ev-attendee-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:999px; background:var(--c-surface-2); font-size:var(--text-xs); color:var(--c-text-secondary); }
|
||||
|
||||
/* ============================================================
|
||||
SERVICES / MATCHING (Sitting & Walks Anbieter-Suche)
|
||||
============================================================ */
|
||||
.svc-matching-layout { display:flex; flex-direction:column; gap:var(--space-4); padding:var(--space-3) 0; }
|
||||
.svc-own-offer { padding:var(--space-4); }
|
||||
.svc-own-offer-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-3); }
|
||||
.svc-own-offer-title { font-weight:var(--weight-semibold); font-size:var(--text-base); }
|
||||
.svc-login-hint { font-size:var(--text-sm); color:var(--c-text-muted); }
|
||||
.svc-toggle { position:relative; display:inline-block; width:44px; height:24px; cursor:pointer; }
|
||||
.svc-toggle input { opacity:0; width:0; height:0; }
|
||||
.svc-toggle-slider { position:absolute; inset:0; background:var(--c-border); border-radius:var(--radius-full); transition:background var(--transition-fast); }
|
||||
.svc-toggle-slider::before { content:''; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform var(--transition-fast); box-shadow:0 1px 3px rgba(0,0,0,.2); }
|
||||
.svc-toggle input:checked + .svc-toggle-slider { background:var(--c-primary); }
|
||||
.svc-toggle input:checked + .svc-toggle-slider::before { transform:translateX(20px); }
|
||||
.svc-offer-form { display:flex; flex-direction:column; gap:var(--space-3); }
|
||||
.svc-offer-form--hidden { display:none; }
|
||||
.svc-hint { color:var(--c-text-secondary); font-size:var(--text-sm); text-align:center; padding:var(--space-6) 0; }
|
||||
.svc-results-list { display:flex; flex-direction:column; gap:var(--space-3); }
|
||||
.svc-card { display:flex; align-items:flex-start; gap:var(--space-3); padding:var(--space-4); background:var(--c-surface); border-radius:var(--radius-lg); border:1px solid var(--c-border-light); box-shadow:var(--shadow-xs); }
|
||||
.svc-card-avatar { width:44px; height:44px; border-radius:var(--radius-full); background:var(--c-primary-subtle); color:var(--c-primary); display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; }
|
||||
.svc-card-body { flex:1; min-width:0; }
|
||||
.svc-card-name { font-weight:var(--weight-semibold); margin-bottom:var(--space-1); }
|
||||
.svc-card-dist { font-size:var(--text-xs); color:var(--c-text-muted); margin-bottom:var(--space-1); }
|
||||
.svc-card-desc { font-size:var(--text-sm); color:var(--c-text-secondary); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
|
||||
.svc-card-side { display:flex; flex-direction:column; align-items:flex-end; gap:var(--space-2); flex-shrink:0; }
|
||||
.svc-card-price { font-weight:var(--weight-bold); color:var(--c-primary); font-size:var(--text-sm); }
|
||||
|
||||
/* ============================================================
|
||||
HELP TOOLTIP
|
||||
============================================================ */
|
||||
.by-help-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-surface-2);
|
||||
color: var(--c-text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.by-help-btn:hover { background: var(--c-primary-subtle, #e8f0fe); color: var(--c-primary); }
|
||||
|
||||
.by-help-tooltip {
|
||||
position: absolute;
|
||||
z-index: 9000;
|
||||
background: var(--c-text);
|
||||
color: var(--c-bg);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 240px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* SVG-Icon-Variante (Phosphor) */
|
||||
.empty-state-icon > svg.ph-icon,
|
||||
svg.empty-state-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--c-text-muted);
|
||||
opacity: .5;
|
||||
}
|
||||
.empty-state-cta {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ const API = (() => {
|
|||
symptomCheck(dogId, symptoms) {
|
||||
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
||||
},
|
||||
gewichtVerlauf(dogId) {
|
||||
return get(`/dogs/${dogId}/health/gewicht`);
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -230,10 +233,13 @@ const API = (() => {
|
|||
const q = new URLSearchParams(params).toString();
|
||||
return get(`/events${q ? '?' + q : ''}`);
|
||||
},
|
||||
get(id) { return get(`/events/${id}`); },
|
||||
create(data) { return post('/events', data); },
|
||||
update(id, data) { return patch(`/events/${id}`, data); },
|
||||
delete(id) { return del(`/events/${id}`); },
|
||||
get(id) { return get(`/events/${id}`); },
|
||||
create(data) { return post('/events', data); },
|
||||
update(id, data) { return patch(`/events/${id}`, data); },
|
||||
delete(id) { return del(`/events/${id}`); },
|
||||
rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
|
||||
cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
|
||||
listRsvp(id) { return get(`/events/${id}/rsvp`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -445,6 +451,20 @@ const API = (() => {
|
|||
delete(id) { return del(`/notifications/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SERVICE-ANGEBOTE (Sitting & Walks Matching)
|
||||
// ----------------------------------------------------------
|
||||
const services = {
|
||||
list(type, lat = null, lon = null, radius = 20) {
|
||||
const p = new URLSearchParams({ type, radius });
|
||||
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
|
||||
return get(`/services?${p}`);
|
||||
},
|
||||
me() { return get('/services/me'); },
|
||||
upsert(data) { return post('/services', data); },
|
||||
deactivate(id) { return del(`/services/${id}`); },
|
||||
};
|
||||
|
||||
const importData = {
|
||||
notestation(dogId, file) {
|
||||
const fd = new FormData();
|
||||
|
|
@ -477,7 +497,7 @@ const API = (() => {
|
|||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications,
|
||||
friends, chat, webcal, importData, sharing, widget, notifications, services,
|
||||
subscribeToPush, getLocation,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '129'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,20 @@ window.Page_diary = (() => {
|
|||
UI.setLoading(btn, false);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// EMPTY-STATE HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LISTE RENDERN — Timeline gruppiert nach Monat
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -214,12 +228,12 @@ window.Page_diary = (() => {
|
|||
if (!listEl) return;
|
||||
|
||||
if (_entries.length === 0) {
|
||||
listEl.innerHTML = UI.emptyState({
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
|
||||
title: 'Noch keine Einträge',
|
||||
text: 'Halte besondere Momente mit deinem Hund fest.',
|
||||
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
|
||||
});
|
||||
listEl.innerHTML = _emptyState(
|
||||
'book-open',
|
||||
'Noch keine Tagebucheinträge',
|
||||
'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
|
||||
`<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
|
||||
);
|
||||
listEl.querySelector('#diary-first-entry')
|
||||
?.addEventListener('click', () => _showForm(null));
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -582,6 +582,7 @@ window.Page_dog_profile = (() => {
|
|||
<label class="form-label">
|
||||
Rasse
|
||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||
${UI.help('Die Rasse wird für Rasseninformationen und Statistiken verwendet.')}
|
||||
</label>
|
||||
<input class="form-control" type="text" name="rasse"
|
||||
value="${_esc(dog?.rasse || '')}"
|
||||
|
|
@ -612,7 +613,10 @@ window.Page_dog_profile = (() => {
|
|||
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Chip-Nummer</label>
|
||||
<label class="form-label">
|
||||
Chip-Nummer
|
||||
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
|
||||
</label>
|
||||
<input class="form-control" type="text" name="chip_nr"
|
||||
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ window.Page_events = (() => {
|
|||
let _map = null;
|
||||
let _markers = [];
|
||||
let _clusterGroup = null;
|
||||
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Phosphor-Icon-Helper
|
||||
|
|
@ -47,6 +48,17 @@ window.Page_events = (() => {
|
|||
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// init
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -133,7 +145,11 @@ window.Page_events = (() => {
|
|||
|
||||
const filtered = _filtered();
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
|
||||
listEl.innerHTML = _emptyState(
|
||||
'calendar-blank',
|
||||
'Keine Events in der Nähe',
|
||||
'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +201,7 @@ window.Page_events = (() => {
|
|||
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
|
||||
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
|
||||
</div>
|
||||
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
|
||||
${ev.link ? `<div class="events-card-actions">
|
||||
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
${_icon('arrow-square-out')} Details
|
||||
|
|
@ -326,12 +343,26 @@ window.Page_events = (() => {
|
|||
let ev;
|
||||
try { ev = await API.events.get(id); } catch { return; }
|
||||
|
||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
const isVdh = ev.quelle === 'vdh';
|
||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||
const d = new Date(ev.datum + 'T00:00:00');
|
||||
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const isOwn = _state.user?.id === ev.user_id;
|
||||
const isVdh = ev.quelle === 'vdh';
|
||||
const myRsvp = _myRsvp[id] ?? null;
|
||||
|
||||
// RSVP-Bar (nur für eingeloggte User)
|
||||
const rsvpBar = _state.user ? `
|
||||
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
|
||||
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
|
||||
${_icon('check-circle')} Ich komme
|
||||
</button>
|
||||
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
|
||||
${_icon('question')} Vielleicht
|
||||
</button>
|
||||
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
|
||||
</div>
|
||||
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
|
||||
|
||||
const body = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
|
|
@ -348,6 +379,8 @@ window.Page_events = (() => {
|
|||
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
|
||||
</div>
|
||||
${rsvpBar}
|
||||
<div id="ev-attendees-panel-${id}"></div>
|
||||
`;
|
||||
|
||||
const footer = isOwn ? `
|
||||
|
|
@ -365,6 +398,80 @@ window.Page_events = (() => {
|
|||
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
|
||||
});
|
||||
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
|
||||
|
||||
// RSVP-Buttons
|
||||
document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
|
||||
btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
|
||||
});
|
||||
}
|
||||
|
||||
async function _handleRsvp(eventId, status) {
|
||||
const current = _myRsvp[eventId] ?? null;
|
||||
try {
|
||||
if (current === status) {
|
||||
// Toggle off → absagen
|
||||
await API.events.cancelRsvp(eventId);
|
||||
_myRsvp[eventId] = null;
|
||||
} else {
|
||||
const res = await API.events.rsvp(eventId, status);
|
||||
_myRsvp[eventId] = status;
|
||||
// Teilnehmerzähler aktualisieren
|
||||
_updateAttendeeCount(eventId, res.rsvp_count);
|
||||
}
|
||||
// Button-Styles aktualisieren
|
||||
document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
|
||||
});
|
||||
// Bei Absage Zähler neu laden
|
||||
if (current === status) {
|
||||
const attendees = await API.events.listRsvp(eventId);
|
||||
const goingCount = attendees.filter(a => a.status === 'going').length;
|
||||
_updateAttendeeCount(eventId, goingCount);
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function _updateAttendeeCount(eventId, count) {
|
||||
// Im Modal
|
||||
const span = document.getElementById(`ev-attendees-${eventId}`);
|
||||
if (span) {
|
||||
if (count > 0) {
|
||||
span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
|
||||
span.style.display = '';
|
||||
} else {
|
||||
span.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// In der Listenansicht (Event-Objekt aktualisieren)
|
||||
const ev = _events.find(x => x.id === eventId);
|
||||
if (ev) {
|
||||
ev.rsvp_count = count;
|
||||
// Karte neu rendern falls sichtbar
|
||||
const card = document.querySelector(`[data-ev-id="${eventId}"]`);
|
||||
if (card) card.outerHTML = _cardHTML(ev);
|
||||
}
|
||||
}
|
||||
|
||||
async function _showAttendees(eventId) {
|
||||
const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
|
||||
if (!panel) return;
|
||||
if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
|
||||
try {
|
||||
const attendees = await API.events.listRsvp(eventId);
|
||||
if (!attendees.length) { panel.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
|
||||
else {
|
||||
panel.innerHTML = `
|
||||
<div class="ev-attendees-list">
|
||||
${attendees.map(a => `
|
||||
<span class="ev-attendee-chip">
|
||||
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
|
||||
${UI.escHtml(a.name)}
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
panel.dataset.loaded = '1';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function _deleteEvent(ev) {
|
||||
|
|
@ -548,6 +655,14 @@ window.Page_events = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
|
||||
const attendeesBtn = e.target.closest('[data-ev-attendees]');
|
||||
if (attendeesBtn) {
|
||||
e.stopPropagation();
|
||||
_showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
|
||||
return;
|
||||
}
|
||||
|
||||
// Karten-Klick → Detail
|
||||
const card = e.target.closest('[data-ev-id]');
|
||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||
|
|
|
|||
|
|
@ -366,19 +366,15 @@ window.Page_friends = (() => {
|
|||
const el = _container.querySelector('#fr-list');
|
||||
|
||||
if (!list.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border);
|
||||
margin-bottom:var(--space-3)" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#paw-print"></use>
|
||||
</svg>
|
||||
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p>
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||||
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
el.innerHTML = _emptyState(
|
||||
'users-three',
|
||||
'Noch keine Freunde',
|
||||
'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
|
||||
`<button class="btn btn-primary" id="fr-empty-search">Freunde suchen</button>`
|
||||
);
|
||||
el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
|
||||
_container.querySelector('#fr-search')?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -774,6 +770,17 @@ window.Page_friends = (() => {
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
|
||||
|
||||
|
|
|
|||
|
|
@ -321,6 +321,11 @@ window.Page_health = (() => {
|
|||
} catch (err) {
|
||||
// silent fail
|
||||
}
|
||||
try {
|
||||
_data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
|
||||
} catch (err) {
|
||||
_data['gewicht_chart'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -347,15 +352,33 @@ window.Page_health = (() => {
|
|||
_bindTabEvents(content);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// EMPTY-STATE HELPER
|
||||
// ----------------------------------------------------------
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// IMPFUNGEN — mit Ampel-Status
|
||||
// ----------------------------------------------------------
|
||||
function _renderImpfungen(entries) {
|
||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
|
||||
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
|
||||
|
||||
if (!entries.length) return UI.emptyState({
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
|
||||
});
|
||||
if (!entries.length) return _emptyState(
|
||||
'syringe',
|
||||
'Noch keine Impfungen',
|
||||
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
|
||||
addBtn
|
||||
);
|
||||
|
||||
const items = entries.map(e => {
|
||||
const ampel = _impfAmpel(e.naechstes);
|
||||
|
|
@ -453,7 +476,8 @@ window.Page_health = (() => {
|
|||
</div>`;
|
||||
})() : '';
|
||||
|
||||
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
|
||||
const chartEntries = _data['gewicht_chart'] || [];
|
||||
const chart = _renderWeightChart(chartEntries);
|
||||
|
||||
const items = sorted.slice().reverse().map(e => `
|
||||
<div class="health-card" data-id="${e.id}" data-action="open-entry"
|
||||
|
|
@ -478,7 +502,13 @@ window.Page_health = (() => {
|
|||
</div>
|
||||
${deltaHtml}
|
||||
</div>
|
||||
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
|
||||
${chart ? `<div class="health-chart-wrap">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
|
||||
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
|
||||
</div>
|
||||
${chart}
|
||||
</div>` : ''}
|
||||
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
|
||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||||
`;
|
||||
|
|
@ -556,6 +586,62 @@ window.Page_health = (() => {
|
|||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
|
||||
// ----------------------------------------------------------
|
||||
function _renderWeightChart(entries) {
|
||||
// entries: [{datum, gewicht}, ...]
|
||||
if (!entries || entries.length < 2) {
|
||||
return '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
|
||||
}
|
||||
|
||||
const W = 300, H = 120, PAD = 24;
|
||||
const weights = entries.map(e => e.gewicht);
|
||||
const min = Math.min(...weights), max = Math.max(...weights);
|
||||
const range = max - min || 1;
|
||||
|
||||
// x: gleichmäßig verteilt, y: normalisiert
|
||||
const pts = entries.map((e, i) => {
|
||||
const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
|
||||
const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
|
||||
return { x, y, ...e };
|
||||
});
|
||||
|
||||
const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
|
||||
const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
|
||||
|
||||
// Datenpunkte + Tooltips als title-Elemente
|
||||
const circles = pts.map(p =>
|
||||
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
|
||||
<title>${p.datum}: ${p.gewicht} kg</title>
|
||||
</circle>`
|
||||
).join('');
|
||||
|
||||
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
|
||||
return `
|
||||
<div class="health-chart-wrap">
|
||||
<div class="health-chart-title">Gewichtsverlauf</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
|
||||
<defs>
|
||||
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
|
||||
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="${area}" fill="url(#${gId})"/>
|
||||
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${circles}
|
||||
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
|
||||
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
|
||||
</svg>
|
||||
<div class="health-chart-labels">
|
||||
<span>${entries[0].datum}</span>
|
||||
<span>${entries[entries.length - 1].datum}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LÄUFIGKEIT — Timeline + Vorhersage
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -927,7 +1013,10 @@ window.Page_health = (() => {
|
|||
|
||||
const uploadField = t === 'dokument' ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Datei (JPG, PNG, PDF)</label>
|
||||
<label class="form-label">
|
||||
Datei (JPG, PNG, PDF)
|
||||
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
|
||||
</label>
|
||||
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
|
||||
</div>
|
||||
` : '';
|
||||
|
|
|
|||
|
|
@ -257,12 +257,12 @@ window.Page_lost = (() => {
|
|||
if (!listEl) return;
|
||||
|
||||
if (_reports.length === 0) {
|
||||
listEl.innerHTML = UI.emptyState({
|
||||
icon : '🐾',
|
||||
title : 'Keine vermissten Hunde',
|
||||
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
|
||||
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
|
||||
});
|
||||
listEl.innerHTML = _emptyState(
|
||||
'magnifying-glass',
|
||||
'Aktuell kein vermisster Hund gemeldet',
|
||||
'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.',
|
||||
`<button class="btn btn-primary" id="lost-empty-report">Vermissten melden</button>`
|
||||
);
|
||||
listEl.querySelector('#lost-empty-report')
|
||||
?.addEventListener('click', _showReportForm);
|
||||
return;
|
||||
|
|
@ -680,6 +680,17 @@ window.Page_lost = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -45,13 +45,31 @@ window.Page_routes = (() => {
|
|||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _emptyState(icon, title, text, cta = '') {
|
||||
return `<div class="empty-state">
|
||||
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||
</svg>
|
||||
<div class="empty-state-title">${title}</div>
|
||||
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
|
||||
try { _userPos = await API.getLocation(); } catch {}
|
||||
_loadData();
|
||||
await _loadData();
|
||||
|
||||
// Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen
|
||||
const params = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const deepId = params.get('id');
|
||||
if (deepId) {
|
||||
_openDetail(parseInt(deepId, 10));
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadLeaflet() {
|
||||
|
|
@ -460,20 +478,12 @@ window.Page_routes = (() => {
|
|||
</div>`;
|
||||
} else {
|
||||
// Noch gar keine eigenen Routen
|
||||
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding">
|
||||
<div class="rk-empty-icon">🥾</div>
|
||||
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
|
||||
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.</p>
|
||||
<div class="rk-empty-features">
|
||||
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div>
|
||||
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
|
||||
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
|
||||
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
|
||||
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
|
||||
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
|
||||
</div>`;
|
||||
grid.innerHTML = _emptyState(
|
||||
'map-trifold',
|
||||
'Noch keine Routen',
|
||||
'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
|
||||
`<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`
|
||||
);
|
||||
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
|
||||
App.navigate('map');
|
||||
setTimeout(() => window.Page_map?.startRecording?.(), 600);
|
||||
|
|
@ -688,6 +698,8 @@ window.Page_routes = (() => {
|
|||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
|
||||
<button type="button" class="btn btn-secondary" id="rd-share" title="Route teilen">${UI.icon('share')}</button>
|
||||
<button type="button" class="btn btn-secondary" id="rd-send-friend" title="An Freund senden">${UI.icon('chat-circle-dots')} An Freund senden</button>
|
||||
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
|
||||
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
|
||||
</button>
|
||||
|
|
@ -700,6 +712,23 @@ window.Page_routes = (() => {
|
|||
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
|
||||
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
|
||||
|
||||
// Teilen-Button
|
||||
document.getElementById('rd-share')?.addEventListener('click', () => {
|
||||
const shareUrl = location.origin + '/#routes?id=' + route.id;
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: route.name, url: shareUrl }).catch(() => {});
|
||||
} else {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
UI.toast.success('Link kopiert!');
|
||||
}).catch(() => {
|
||||
UI.toast.error('Link konnte nicht kopiert werden.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// An Freund senden
|
||||
document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route));
|
||||
|
||||
// Sichtbarkeit toggle
|
||||
document.getElementById('rd-vis')?.addEventListener('click', async () => {
|
||||
try {
|
||||
|
|
@ -1108,6 +1137,7 @@ window.Page_routes = (() => {
|
|||
</label>
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" id="ri-public"> Öffentlich
|
||||
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1173,6 +1203,71 @@ window.Page_routes = (() => {
|
|||
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// An Freund senden
|
||||
// ----------------------------------------------------------
|
||||
async function _openSendToFriendModal(route) {
|
||||
const shareUrl = location.origin + '/#routes?id=' + route.id;
|
||||
|
||||
// Freunde laden
|
||||
let friends = [];
|
||||
try {
|
||||
friends = await API.friends.list();
|
||||
} catch (err) {
|
||||
UI.toast.error('Freunde konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!friends.length) {
|
||||
UI.toast.info('Du hast noch keine Freunde hinzugefügt.');
|
||||
return;
|
||||
}
|
||||
|
||||
const friendRows = friends.map(f => {
|
||||
const initial = (f.name || '?')[0].toUpperCase();
|
||||
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${_esc(f.name || 'Anonym')}"
|
||||
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
|
||||
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
|
||||
onmouseover="this.style.background='var(--c-surface-2)'"
|
||||
onmouseout="this.style.background=''">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
|
||||
color:#fff;display:flex;align-items:center;justify-content:center;
|
||||
font-weight:600;flex-shrink:0">${_esc(initial)}</div>
|
||||
<span>${_esc(f.name || 'Anonym')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const body = `<div id="rk-friend-list">${friendRows}</div>`;
|
||||
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('chat-circle-dots')} An Freund senden`,
|
||||
body,
|
||||
footer,
|
||||
});
|
||||
|
||||
document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
|
||||
const row = e.target.closest('.rk-friend-row');
|
||||
if (!row) return;
|
||||
|
||||
const partnerId = parseInt(row.dataset.id, 10);
|
||||
const partnerName = row.dataset.name;
|
||||
|
||||
try {
|
||||
const conv = await API.chat.start(partnerId);
|
||||
const convId = conv.id;
|
||||
const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
|
||||
await API.chat.send(convId, text);
|
||||
UI.modal.close();
|
||||
UI.toast.success(`Gesendet an ${partnerName}`);
|
||||
} catch (err) {
|
||||
UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -18,13 +18,17 @@ window.Page_sitting = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// State
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _state = null;
|
||||
let _tab = 'suchen'; // suchen | profil | anfragen
|
||||
let _sitters = [];
|
||||
let _mySitter = null;
|
||||
let _myRequests = [];
|
||||
let _inbox = [];
|
||||
let _container = null;
|
||||
let _state = null;
|
||||
let _tab = 'suchen'; // suchen | profil | anfragen | matching
|
||||
let _sitters = [];
|
||||
let _mySitter = null;
|
||||
let _myRequests = [];
|
||||
let _inbox = [];
|
||||
// Matching-State
|
||||
let _matchResults = null; // null = noch nicht geladen
|
||||
let _matchLoading = false;
|
||||
let _myServiceOffer = null; // eigenes Angebot (type='sitting')
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// init
|
||||
|
|
@ -46,6 +50,7 @@ window.Page_sitting = (() => {
|
|||
<div class="sitting-layout">
|
||||
<div class="sitting-tabs by-tabs" id="sit-tabs">
|
||||
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
|
||||
<button class="by-tab" data-sit-tab="matching">${UI.icon('users')} Anbieter</button>
|
||||
${_state.user ? `
|
||||
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
|
||||
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
|
||||
|
|
@ -70,6 +75,7 @@ window.Page_sitting = (() => {
|
|||
tasks.push(API.sitting.me());
|
||||
tasks.push(API.sitting.requests());
|
||||
tasks.push(API.sitting.inbox());
|
||||
tasks.push(API.services.me());
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -78,6 +84,8 @@ window.Page_sitting = (() => {
|
|||
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
|
||||
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
|
||||
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
|
||||
const myOffers = results[4]?.status === 'fulfilled' ? results[4].value : [];
|
||||
_myServiceOffer = myOffers?.find(o => o.type === 'sitting') || null;
|
||||
} catch {}
|
||||
|
||||
_renderTab();
|
||||
|
|
@ -92,6 +100,7 @@ window.Page_sitting = (() => {
|
|||
if (_tab === 'suchen') _renderSuchen(content);
|
||||
if (_tab === 'profil') _renderProfil(content);
|
||||
if (_tab === 'anfragen') _renderAnfragen(content);
|
||||
if (_tab === 'matching') _renderMatching(content);
|
||||
}
|
||||
|
||||
// ---- Tab: Sitter suchen ----
|
||||
|
|
@ -441,6 +450,239 @@ window.Page_sitting = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Tab: Anbieter in deiner Nähe (service_offers Matching)
|
||||
// ----------------------------------------------------------
|
||||
function _renderMatching(el) {
|
||||
const offerActive = _myServiceOffer?.aktiv;
|
||||
const offerDesc = _myServiceOffer?.beschreibung || '';
|
||||
const offerPreis = _myServiceOffer?.preis_pro_tag ?? '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="svc-matching-layout">
|
||||
|
||||
<!-- Eigenes Angebot -->
|
||||
<div class="svc-own-offer by-card">
|
||||
<div class="svc-own-offer-header">
|
||||
<span class="svc-own-offer-title">${UI.icon('handshake')} Mein Angebot</span>
|
||||
${_state.user ? `
|
||||
<label class="svc-toggle" title="${offerActive ? 'Angebot deaktivieren' : 'Angebot aktivieren'}">
|
||||
<input type="checkbox" id="svc-offer-toggle" ${offerActive ? 'checked' : ''}>
|
||||
<span class="svc-toggle-slider"></span>
|
||||
</label>
|
||||
` : `<span class="svc-login-hint">Zum Anbieten bitte anmelden</span>`}
|
||||
</div>
|
||||
${_state.user ? `
|
||||
<form id="svc-offer-form" class="svc-offer-form ${offerActive ? '' : 'svc-offer-form--hidden'}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea class="form-control" name="beschreibung" rows="2"
|
||||
placeholder="Was bietest du an? Erfahrungen, besondere Stärken…">${UI.escape(offerDesc)}</textarea>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Preis/Tag (€, optional)</label>
|
||||
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag"
|
||||
value="${offerPreis}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Umkreis (km)</label>
|
||||
<input class="form-control" type="number" min="1" max="100" name="radius_km"
|
||||
value="${_myServiceOffer?.radius_km ?? 10}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="svc-offer-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="svc-gps-btn">
|
||||
${UI.icon('map-pin')} Position
|
||||
</button>
|
||||
<input type="hidden" name="lat" id="svc-lat" value="${_myServiceOffer?.lat || ''}">
|
||||
<input type="hidden" name="lon" id="svc-lon" value="${_myServiceOffer?.lon || ''}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
${UI.icon('floppy-disk')} Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Anbieter finden -->
|
||||
<div class="svc-search-section">
|
||||
<div class="svc-search-header">
|
||||
<span class="by-section-label" style="margin:0">${UI.icon('magnifying-glass')} Anbieter in deiner Nähe</span>
|
||||
<button class="btn btn-primary btn-sm" id="svc-find-btn">
|
||||
${UI.icon('map-pin')} Suchen
|
||||
</button>
|
||||
</div>
|
||||
<div id="svc-results">
|
||||
<p class="svc-hint">Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Toggle
|
||||
document.getElementById('svc-offer-toggle')?.addEventListener('change', async e => {
|
||||
const form = document.getElementById('svc-offer-form');
|
||||
if (e.target.checked) {
|
||||
form?.classList.remove('svc-offer-form--hidden');
|
||||
} else {
|
||||
form?.classList.add('svc-offer-form--hidden');
|
||||
if (_myServiceOffer) {
|
||||
try {
|
||||
await API.services.deactivate(_myServiceOffer.id);
|
||||
_myServiceOffer = { ..._myServiceOffer, aktiv: 0 };
|
||||
UI.toast('Angebot deaktiviert.');
|
||||
} catch (err) { UI.toast(err.message, 'error'); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GPS
|
||||
document.getElementById('svc-gps-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('svc-gps-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
|
||||
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
|
||||
UI.toast('Position gespeichert.');
|
||||
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// Formular speichern
|
||||
document.getElementById('svc-offer-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const fd = new FormData(form);
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
|
||||
// Wenn noch keine Position gespeichert, GPS holen
|
||||
if (!fd.get('lat') || !fd.get('lon')) {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
|
||||
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
|
||||
fd.set('lat', pos.lat.toFixed(6));
|
||||
fd.set('lon', pos.lon.toFixed(6));
|
||||
} catch {
|
||||
UI.toast('Bitte GPS-Position ermitteln.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; }
|
||||
try {
|
||||
const payload = {
|
||||
type: 'sitting',
|
||||
beschreibung: fd.get('beschreibung') || null,
|
||||
preis_pro_tag: fd.get('preis_pro_tag') ? parseFloat(fd.get('preis_pro_tag')) : null,
|
||||
lat: parseFloat(fd.get('lat')),
|
||||
lon: parseFloat(fd.get('lon')),
|
||||
radius_km: parseInt(fd.get('radius_km')) || 10,
|
||||
};
|
||||
_myServiceOffer = await API.services.upsert(payload);
|
||||
UI.toast('Angebot gespeichert!');
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
} finally {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = `${UI.icon('floppy-disk')} Speichern`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Suche
|
||||
document.getElementById('svc-find-btn')?.addEventListener('click', _searchProviders);
|
||||
}
|
||||
|
||||
async function _searchProviders() {
|
||||
if (_matchLoading) return;
|
||||
_matchLoading = true;
|
||||
const btn = document.getElementById('svc-find-btn');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = `${UI.icon('spinner')} Suche…`; }
|
||||
|
||||
const resultsEl = document.getElementById('svc-results');
|
||||
|
||||
let pos = null;
|
||||
try {
|
||||
pos = await API.getLocation();
|
||||
} catch {
|
||||
UI.toast('GPS nicht verfügbar — Suche ohne Entfernung.', 'error');
|
||||
}
|
||||
|
||||
try {
|
||||
const offers = await API.services.list('sitting', pos?.lat ?? null, pos?.lon ?? null, 50);
|
||||
_matchResults = offers;
|
||||
_renderMatchResults(resultsEl, pos);
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
if (resultsEl) resultsEl.innerHTML = `<p class="svc-hint">Fehler beim Laden.</p>`;
|
||||
}
|
||||
|
||||
_matchLoading = false;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `${UI.icon('map-pin')} Erneut suchen`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderMatchResults(el, pos) {
|
||||
if (!el) return;
|
||||
const list = _matchResults || [];
|
||||
|
||||
// Eigene Angebote ausblenden
|
||||
const filtered = list.filter(o => o.user_id !== _state.user?.id);
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = `<p class="svc-hint">Keine Anbieter in deiner Nähe gefunden.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="svc-results-list">
|
||||
${filtered.map(o => _serviceCardHTML(o)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _serviceCardHTML(o) {
|
||||
const dist = o.distanz_km != null ? `${o.distanz_km} km entfernt` : '';
|
||||
const preis = o.preis_pro_tag != null ? `${o.preis_pro_tag.toFixed(0)} €/Tag` : 'Preis anfragen';
|
||||
return `
|
||||
<div class="svc-card">
|
||||
<div class="svc-card-avatar">${UI.icon('paw-print')}</div>
|
||||
<div class="svc-card-body">
|
||||
<div class="svc-card-name">${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}</div>
|
||||
${dist ? `<div class="svc-card-dist">${UI.icon('map-pin')} ${dist}</div>` : ''}
|
||||
${o.beschreibung ? `<div class="svc-card-desc">${UI.escape(o.beschreibung)}</div>` : ''}
|
||||
</div>
|
||||
<div class="svc-card-side">
|
||||
<div class="svc-card-price">${preis}</div>
|
||||
<button class="btn btn-primary btn-sm" data-svc-chat="${o.user_id}">
|
||||
${UI.icon('chat-circle')} Kontakt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _openChatWithProvider(userId) {
|
||||
if (!_state.user) {
|
||||
UI.toast('Bitte zuerst anmelden.', 'error');
|
||||
App.navigate('settings');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { conversation_id } = await API.chat.start(userId);
|
||||
App.navigate('chat', true, { conversation_id });
|
||||
} catch (err) {
|
||||
UI.toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Click-Handler
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -467,6 +709,13 @@ window.Page_sitting = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Anbieter kontaktieren
|
||||
const chatBtn = e.target.closest('[data-svc-chat]');
|
||||
if (chatBtn) {
|
||||
_openChatWithProvider(parseInt(chatBtn.dataset.svcChat));
|
||||
return;
|
||||
}
|
||||
|
||||
// Anfragen-Aktionen
|
||||
const acceptBtn = e.target.closest('[data-sit-accept]');
|
||||
const declineBtn = e.target.closest('[data-sit-decline]');
|
||||
|
|
|
|||
|
|
@ -268,6 +268,37 @@ const UI = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
|
||||
// ----------------------------------------------------------
|
||||
function help(text) {
|
||||
return `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
|
||||
<use href="/icons/phosphor.svg#question"></use>
|
||||
</svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Event-Delegation für Help-Tooltips — einmalig registrieren
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.by-help-btn');
|
||||
if (!btn) {
|
||||
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'by-help-tooltip';
|
||||
tip.textContent = btn.dataset.help;
|
||||
document.body.appendChild(tip);
|
||||
const r = btn.getBoundingClientRect();
|
||||
tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
|
||||
tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
|
||||
const maxL = window.innerWidth - tip.offsetWidth - 8;
|
||||
if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
|
||||
});
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
toast, modal,
|
||||
|
|
@ -276,7 +307,7 @@ const UI = (() => {
|
|||
emptyState, time,
|
||||
setupPhotoPreview, scrollTop, skeleton,
|
||||
icon: _svgIcon,
|
||||
escape,
|
||||
escape, help,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v156';
|
||||
const CACHE_VERSION = 'by-v157';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue