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
|
|
@ -464,6 +464,8 @@ def _migrate(conn_factory):
|
|||
("dogs", "foto_zoom", "REAL NOT NULL DEFAULT 1.0"),
|
||||
("dogs", "foto_offset_x", "REAL NOT NULL DEFAULT 0.0"),
|
||||
("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"),
|
||||
# Tagebuch: Ortsname (POI/Adresse)
|
||||
("diary", "location_name", "TEXT"),
|
||||
]
|
||||
with conn_factory() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
|
@ -736,3 +738,19 @@ def _migrate(conn_factory):
|
|||
CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
|
||||
""")
|
||||
|
||||
# Walk-Einladungen (RSVP)
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS walk_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'invited',
|
||||
invited_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
responded_at TEXT,
|
||||
UNIQUE(walk_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_walk_inv_walk ON walk_invitations(walk_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status);
|
||||
""")
|
||||
logger.info("Migration: walk_invitations Tabelle bereit.")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|||
from typing import Optional, List
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from routes.push import send_push_to_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -51,6 +52,12 @@ class WalkUpdate(BaseModel):
|
|||
class JoinRequest(BaseModel):
|
||||
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
friend_id: int
|
||||
|
||||
class RsvpRequest(BaseModel):
|
||||
status: str # 'yes' | 'maybe' | 'no'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis)
|
||||
|
|
@ -181,6 +188,161 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
|
|||
return unique[:20]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/walks/{id}/invite-candidates — einladbare Freunde
|
||||
# WICHTIG: Muss VOR /{walk_id} stehen
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/{walk_id}/invite-candidates")
|
||||
async def invite_candidates(walk_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
|
||||
if not walk:
|
||||
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||
if walk['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nur der Veranstalter kann diese Liste abrufen.")
|
||||
|
||||
# Freunde die noch nicht eingeladen wurden
|
||||
already = {r[0] for r in conn.execute(
|
||||
"SELECT user_id FROM walk_invitations WHERE walk_id=?", (walk_id,)
|
||||
).fetchall()}
|
||||
|
||||
friends = _get_accepted_friends(user['id'])
|
||||
return [f for f in friends if f['friend_id'] not in already]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/walks/{id}/invite — Freund einladen (nur Veranstalter)
|
||||
# WICHTIG: Muss VOR /{walk_id} stehen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{walk_id}/invite", status_code=201)
|
||||
async def invite_friend(walk_id: int, data: InviteRequest, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone()
|
||||
if not walk:
|
||||
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||
if walk['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nur der Veranstalter kann einladen.")
|
||||
|
||||
# Freundschaft prüfen
|
||||
friendship = conn.execute("""
|
||||
SELECT 1 FROM friendships
|
||||
WHERE status='accepted'
|
||||
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
|
||||
""", (user['id'], data.friend_id, data.friend_id, user['id'])).fetchone()
|
||||
if not friendship:
|
||||
raise HTTPException(400, "Dieser Nutzer ist nicht in deiner Freundesliste.")
|
||||
|
||||
# Bereits eingeladen?
|
||||
existing = conn.execute(
|
||||
"SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?",
|
||||
(walk_id, data.friend_id)
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(409, f"Nutzer wurde bereits eingeladen (Status: {existing['status']}).")
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO walk_invitations (walk_id, user_id, status) VALUES (?, ?, 'invited')",
|
||||
(walk_id, data.friend_id)
|
||||
)
|
||||
|
||||
friend = conn.execute("SELECT name FROM users WHERE id=?", (data.friend_id,)).fetchone()
|
||||
|
||||
# Push-Notification
|
||||
send_push_to_user(data.friend_id, {
|
||||
"type": "walk_invite",
|
||||
"title": f"Einladung: {walk['titel']}",
|
||||
"body": f"{user['name']} lädt dich zu einem Gassi-Treffen ein ({walk['datum']} {walk['uhrzeit']})",
|
||||
"walk_id": walk_id,
|
||||
})
|
||||
|
||||
return {"status": "invited", "friend_name": friend['name'] if friend else ""}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/walks/{id}/rsvp — RSVP setzen (yes / maybe / no)
|
||||
# WICHTIG: Muss VOR /{walk_id} stehen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{walk_id}/rsvp")
|
||||
async def rsvp_walk(walk_id: int, data: RsvpRequest, user=Depends(get_current_user)):
|
||||
if data.status not in ('yes', 'maybe', 'no'):
|
||||
raise HTTPException(400, "Ungültiger RSVP-Status. Erlaubt: yes, maybe, no")
|
||||
|
||||
with db() as conn:
|
||||
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
|
||||
if not walk:
|
||||
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||
|
||||
inv = conn.execute(
|
||||
"SELECT * FROM walk_invitations WHERE walk_id=? AND user_id=?",
|
||||
(walk_id, user['id'])
|
||||
).fetchone()
|
||||
if not inv:
|
||||
raise HTTPException(403, "Du wurdest nicht zu diesem Treffen eingeladen.")
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE walk_invitations
|
||||
SET status=?, responded_at=datetime('now')
|
||||
WHERE walk_id=? AND user_id=?""",
|
||||
(data.status, walk_id, user['id'])
|
||||
)
|
||||
|
||||
return {"status": data.status}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/walks/{id}/participants — Teilnehmerliste mit RSVP
|
||||
# WICHTIG: Muss VOR /{walk_id} stehen
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/{walk_id}/participants")
|
||||
async def get_participants(walk_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
|
||||
if not walk:
|
||||
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||
|
||||
is_organizer = walk['user_id'] == user['id']
|
||||
|
||||
if is_organizer:
|
||||
# Veranstalter sieht alle Einladungen
|
||||
invitations = conn.execute("""
|
||||
SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at,
|
||||
u.name AS user_name,
|
||||
GROUP_CONCAT(d.name, ', ') AS hunde
|
||||
FROM walk_invitations wi
|
||||
JOIN users u ON u.id = wi.user_id
|
||||
LEFT JOIN walk_participant_dogs wpd
|
||||
ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id
|
||||
LEFT JOIN dogs d ON d.id = wpd.dog_id
|
||||
WHERE wi.walk_id = ?
|
||||
GROUP BY wi.user_id
|
||||
""", (walk_id,)).fetchall()
|
||||
else:
|
||||
# Eingeladene sehen nur Zugesagte + sich selbst
|
||||
invitations = conn.execute("""
|
||||
SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at,
|
||||
u.name AS user_name,
|
||||
GROUP_CONCAT(d.name, ', ') AS hunde
|
||||
FROM walk_invitations wi
|
||||
JOIN users u ON u.id = wi.user_id
|
||||
LEFT JOIN walk_participant_dogs wpd
|
||||
ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id
|
||||
LEFT JOIN dogs d ON d.id = wpd.dog_id
|
||||
WHERE wi.walk_id = ? AND (wi.status = 'yes' OR wi.user_id = ?)
|
||||
GROUP BY wi.user_id
|
||||
""", (walk_id, user['id'])).fetchall()
|
||||
|
||||
my_invitation = conn.execute(
|
||||
"SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?",
|
||||
(walk_id, user['id'])
|
||||
).fetchone()
|
||||
|
||||
return {
|
||||
"invitations": [dict(r) for r in invitations],
|
||||
"my_rsvp": my_invitation['status'] if my_invitation else None,
|
||||
"is_organizer": is_organizer,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/walks/{id} — Detail mit Teilnehmerliste
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -214,6 +376,20 @@ async def get_walk(walk_id: int):
|
|||
return result
|
||||
|
||||
|
||||
# Helper: accepted friends list for invite-modal (reused from friends route)
|
||||
def _get_accepted_friends(user_id: int):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id,
|
||||
u.name AS friend_name
|
||||
FROM friendships f
|
||||
JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END
|
||||
WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted'
|
||||
ORDER BY u.name
|
||||
""", (user_id, user_id, user_id, user_id)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/walks/{id}
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2602,6 +2602,112 @@ html.modal-open {
|
|||
.walks-participant-name { font-weight: var(--weight-semibold); }
|
||||
.walks-participant-dogs { color: var(--c-text-secondary); }
|
||||
|
||||
/* Walk-Einladungen + RSVP */
|
||||
.walks-invitation-row,
|
||||
.walks-invite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.walks-invitation-row:last-child,
|
||||
.walks-invite-row:last-child { border-bottom: none; }
|
||||
|
||||
.walks-inv-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.walks-inv-avatar--sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.walks-inv-info { flex: 1; min-width: 0; }
|
||||
.walks-inv-name {
|
||||
font-weight: var(--weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.walks-inv-hunde {
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.walks-inv-badge { flex-shrink: 0; }
|
||||
|
||||
/* RSVP-Badges */
|
||||
.walks-rsvp-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.walks-rsvp--yes { background: #d1fae5; color: #065f46; }
|
||||
.walks-rsvp--maybe { background: #fef3c7; color: #92400e; }
|
||||
.walks-rsvp--no { background: #fee2e2; color: #991b1b; }
|
||||
.walks-rsvp--invited { background: var(--c-surface-2); color: var(--c-text-secondary); }
|
||||
|
||||
/* RSVP-Section im Detail-Modal */
|
||||
.walks-rsvp-section {
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--c-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.walks-rsvp-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.walks-rsvp-btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border: 2px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.walks-rsvp-btn:hover {
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-primary-bg, rgba(196,132,58,0.08));
|
||||
}
|
||||
.walks-rsvp-btn.active[data-rsvp="yes"] {
|
||||
border-color: #10b981;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.walks-rsvp-btn.active[data-rsvp="maybe"] {
|
||||
border-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.walks-rsvp-btn.active[data-rsvp="no"] {
|
||||
border-color: #ef4444;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
EVENTS (events.js)
|
||||
------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v203';
|
||||
const CACHE_VERSION = 'by-v205';
|
||||
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