Feature: Walk-Einladungen und RSVP-System

Gassi-Treffen bekommen ein vollständiges Einladungs- und RSVP-System:
- Neue Tabelle walk_invitations (walk_id, user_id, status, Zeitstempel)
- Backend: /invite-candidates, /invite, /rsvp, /participants Endpoints
- Push-Notification beim Einladen
- Frontend: RSVP-Buttons (Zusagen/Vielleicht/Absagen), Teilnehmerliste
  mit Avatar-Initialen und farbkodierten RSVP-Badges, Einladen-Modal
- SW by-v205, APP_VER 173
This commit is contained in:
rene 2026-04-18 14:14:31 +02:00
parent e3230237a2
commit 066b722c5e
7 changed files with 489 additions and 18 deletions

View file

@ -269,13 +269,46 @@ window.Page_walks = (() => {
});
}
// ----------------------------------------------------------
// RSVP-Status → Label + Farbe
// ----------------------------------------------------------
function _rsvpBadge(status) {
if (status === 'yes') return `<span class="walks-rsvp-badge walks-rsvp--yes">Zusage</span>`;
if (status === 'maybe') return `<span class="walks-rsvp-badge walks-rsvp--maybe">Vielleicht</span>`;
if (status === 'no') return `<span class="walks-rsvp-badge walks-rsvp--no">Absage</span>`;
return `<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>`;
}
function _avatarInitials(name) {
const parts = (name || '?').trim().split(/\s+/);
const initials = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: parts[0].slice(0, 2);
return initials.toUpperCase();
}
function _invitationRowHTML(inv) {
return `
<div class="walks-invitation-row">
<div class="walks-inv-avatar">${_avatarInitials(inv.user_name)}</div>
<div class="walks-inv-info">
<div class="walks-inv-name">${_esc(inv.user_name)}</div>
${inv.hunde ? `<div class="walks-inv-hunde">${UI.icon('dog')} ${_esc(inv.hunde)}</div>` : ''}
</div>
<div class="walks-inv-badge">${_rsvpBadge(inv.status)}</div>
</div>`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _openDetail(walkId) {
let walk;
let walk, participantData;
try {
walk = await API.walks.get(walkId);
if (_appState.user) {
try { participantData = await API.walks.participants(walkId); } catch {}
}
} catch (err) {
UI.toast.error(err.message);
return;
@ -286,15 +319,42 @@ window.Page_walks = (() => {
const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer;
const isPast = _isPast(walk.datum);
const spots = walk.max_teilnehmer - walk.teilnehmer_count;
const myRsvp = participantData?.my_rsvp ?? null;
const isInvited = !!myRsvp;
const invitations = participantData?.invitations ?? [];
// Teilnehmerliste
// Teilnehmerliste (join-Teilnehmer, klassisch)
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<span class="walks-participant-name">${UI.icon('user')} ${_esc(t.user_name)}</span>
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<span class="walks-participant-name">${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
: '';
// Einladungsliste
const invListHTML = invitations.length
? invitations.map(inv => _invitationRowHTML(inv)).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Einladungen.</p>`;
// RSVP-Section für eingeladene Nutzer
const rsvpSectionHTML = (isInvited && !isOwn) ? `
<div class="walks-rsvp-section" id="wd-rsvp-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Deine Antwort</div>
<div class="walks-rsvp-buttons">
<button type="button" class="walks-rsvp-btn ${myRsvp === 'yes' ? 'active' : ''}" data-rsvp="yes">
${UI.icon('check')} Zusagen
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp="maybe">
${UI.icon('question')} Vielleicht
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'no' ? 'active' : ''}" data-rsvp="no">
${UI.icon('x')} Absagen
</button>
</div>
</div>
` : '';
const body = `
<div class="walks-detail-header">
@ -316,11 +376,23 @@ window.Page_walks = (() => {
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
` : ''}
${rsvpSectionHTML}
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('users')} Teilnehmer (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('users')} Einladungen</div>
${isOwn && !isPast ? `<button type="button" class="btn btn-secondary btn-sm" id="wd-invite-btn">${UI.icon('user-plus')} Einladen</button>` : ''}
</div>
<div id="wd-inv-list">${invListHTML}</div>
</div>
${walk.teilnehmer?.length ? `
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Beigetreten (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML}
</div>
` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
</p>
@ -380,6 +452,31 @@ window.Page_walks = (() => {
_showEditForm(walk);
});
// Einladen-Button
document.getElementById('wd-invite-btn')?.addEventListener('click', () => {
UI.modal.close();
_showInviteModal(walk);
});
// RSVP-Buttons
document.querySelectorAll('.walks-rsvp-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const status = btn.dataset.rsvp;
try {
await API.walks.rsvp(walk.id, status);
// Buttons aktualisieren
document.querySelectorAll('.walks-rsvp-btn').forEach(b => {
b.classList.toggle('active', b.dataset.rsvp === status);
});
UI.toast.success(
status === 'yes' ? 'Zugesagt!' :
status === 'maybe' ? 'Antwort: Vielleicht.' :
'Abgesagt.'
);
} catch (err) { UI.toast.error(err.message); }
});
});
// Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal)
let _cancelPending = false;
document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => {
@ -442,6 +539,66 @@ window.Page_walks = (() => {
});
}
// ----------------------------------------------------------
// Freunde einladen
// ----------------------------------------------------------
async function _showInviteModal(walk) {
let candidates;
try {
candidates = await API.walks.inviteCandidates(walk.id);
} catch (err) {
UI.toast.error(err.message);
return;
}
const listHTML = candidates.length
? candidates.map(f => `
<div class="walks-invite-row" data-friend-id="${f.friend_id}" data-friend-name="${_esc(f.friend_name)}">
<div class="walks-inv-avatar">${_avatarInitials(f.friend_name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(f.friend_name)}</div>
<button type="button" class="btn btn-primary btn-sm walks-invite-send">
${UI.icon('paper-plane-tilt')} Einladen
</button>
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Alle Freunde wurden bereits eingeladen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr
${walk.ort_name ? `· ${_esc(walk.ort_name)}` : ''}
</p>
<div id="invite-list">${listHTML}</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="invite-back">Zurück</button>
`;
UI.modal.open({ title: `${UI.icon('user-plus')} Freunde einladen`, body, footer });
document.getElementById('invite-back')?.addEventListener('click', () => {
UI.modal.close();
_openDetail(walk.id);
});
document.querySelectorAll('.walks-invite-send').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.walks-invite-row');
const friendId = parseInt(row.dataset.friendId);
const name = row.dataset.friendName;
await UI.asyncButton(btn, async () => {
await API.walks.invite(walk.id, friendId);
row.innerHTML = `
<div class="walks-inv-avatar">${_avatarInitials(name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(name)}</div>
<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>
`;
UI.toast.success(`${name} eingeladen.`);
});
});
});
}
// ----------------------------------------------------------
// Beitreten-Formular (Hunde wählen)
// ----------------------------------------------------------
@ -547,7 +704,15 @@ window.Page_walks = (() => {
<!-- Mini-Karte -->
<div style="position:relative">
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:180px;background:var(--c-surface-2)"></div>
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="wf-map-pin-here" style="
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
z-index:1000;background:var(--c-primary);color:#fff;border:none;
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
display:flex;align-items:center;gap:6px;white-space:nowrap">
${UI.icon('map-pin')} Pin hier setzen
</button>
</div>
<!-- Ort-Chip -->
@ -615,7 +780,7 @@ window.Page_walks = (() => {
function _placeMarker(lat, lon) {
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker.on('dragend', () => {
const p = _miniMarker.getLatLng();
_locLat = p.lat; _locLon = p.lng;
@ -649,16 +814,18 @@ window.Page_walks = (() => {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_miniMap);
_miniMap.invalidateSize();
if (_locLat) {
_placeMarker(lat, lon);
_miniMarker.dragging.disable();
}
if (_locLat) _placeMarker(lat, lon);
_miniMap.on('click', e => {
if (!_mapEditing) return;
_setCoords(e.latlng.lat, e.latlng.lng);
_placeMarker(_locLat, _locLon);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
document.getElementById('wf-map-pin-here')?.addEventListener('click', () => {
const c = _miniMap.getCenter();
_setCoords(c.lat, c.lng);
_placeMarker(c.lat, c.lng);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
}, 150);
});