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:
parent
e3230237a2
commit
066b722c5e
7 changed files with 489 additions and 18 deletions
|
|
@ -227,9 +227,13 @@ const API = (() => {
|
|||
create(data) { return post('/walks', data); },
|
||||
update(id, data) { return patch(`/walks/${id}`, data); },
|
||||
cancel(id) { return del(`/walks/${id}`); },
|
||||
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
|
||||
leave(id) { return del(`/walks/${id}/join`); },
|
||||
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
|
||||
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
|
||||
leave(id) { return del(`/walks/${id}/join`); },
|
||||
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
|
||||
inviteCandidates(id) { return get(`/walks/${id}/invite-candidates`); },
|
||||
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
|
||||
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
|
||||
participants(id) { return get(`/walks/${id}/participants`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '169'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '173'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue