/* ============================================================
BAN YARO — Playdate-Matching
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
============================================================ */
window.Page_playdate = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
let _userPos = null;
let _radius = 10;
let _dogs = [];
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE');
}
function _dogAvatar(foto_url, name, size = 48) {
const initials = UI.escape((name || '?').charAt(0).toUpperCase());
if (foto_url) {
return ` `;
}
return `
${initials}
`;
}
function _statusBadge(status) {
const map = {
pending: ['warning', 'Ausstehend'],
accepted: ['success', 'Angenommen'],
declined: ['danger', 'Abgelehnt'],
};
const [type, label] = map[status] || ['default', status];
const colors = {
warning: 'var(--c-warning, #f59e0b)',
success: 'var(--c-success, #10b981)',
danger: 'var(--c-danger, #ef4444)',
default: 'var(--c-text-muted)',
};
return `${label} `;
}
// ------------------------------------------------------------------
// INIT
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
_render();
_switchTab(_activeTab);
}
function refresh() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
_switchTab(_activeTab);
}
function onDogChange() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
if (_activeTab === 'listings') _loadListings();
}
// ------------------------------------------------------------------
// RENDER — Grundstruktur mit Tabs
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
In der Nähe
Meine Inserate
Anfragen
0
`;
document.getElementById('playdate-tabs').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
}
function _switchTab(tab) {
_activeTab = tab;
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
const content = document.getElementById('playdate-content');
if (!content) return;
if (tab === 'nearby') _renderNearby(content);
if (tab === 'listings') _renderListings(content);
if (tab === 'requests') _renderRequests(content);
}
// ------------------------------------------------------------------
// TAB: IN DER NÄHE
// ------------------------------------------------------------------
async function _renderNearby(el) {
el.innerHTML = `
${UI.icon('map-pin')}
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
5 km
10 km
25 km
50 km
${UI.icon('crosshair')} Standort aktualisieren
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
`;
document.getElementById('nearby-radius').addEventListener('change', e => {
_radius = parseInt(e.target.value, 10);
_loadNearby();
});
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
const btn = document.getElementById('nearby-locate-btn');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort aktualisiert';
await _loadNearby();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(btn, false);
}
});
if (!_userPos) {
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort bekannt';
} catch {
document.getElementById('nearby-results').innerHTML = `
${UI.icon('map-pin')}
Standort konnte nicht automatisch ermittelt werden.
Klicke auf "Standort aktualisieren".
`;
return;
}
}
await _loadNearby();
}
async function _loadNearby() {
if (!_userPos) return;
const resultsEl = document.getElementById('nearby-results');
if (!resultsEl) return;
resultsEl.innerHTML = `${UI.icon('spinner')} Suche…
`;
try {
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
if (!data || data.length === 0) {
resultsEl.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Niemand in der Nähe',
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
});
return;
}
resultsEl.innerHTML = `
${data.map(d => _nearbyCard(d)).join('')}
`;
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const toDogId = parseInt(btn.dataset.dogId, 10);
const dogName = btn.dataset.dogName;
_showRequestModal(toDogId, dogName);
});
});
} catch (err) {
resultsEl.innerHTML = `${err.message}
`;
}
}
function _nearbyCard(d) {
return `
${_dogAvatar(d.foto_url, d.dog_name, 56)}
${UI.escape(d.dog_name)}
${d.rasse ? `
${UI.escape(d.rasse)}
` : ''}
${d.alter ? `
${UI.escape(d.alter)}
` : ''}
${UI.icon('map-pin')}
${d.ort_name ? UI.escape(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
${d.geschlecht ? `${UI.escape(d.geschlecht)} ` : ''}
${d.beschreibung ? `
${UI.escape(d.beschreibung)}
` : ''}
${UI.icon('paw-print')} Spielkamerad anfragen
`;
}
function _showRequestModal(toDogId, dogName) {
const formId = 'playdate-req-form';
UI.modal.open({
title: `Anfrage an ${dogName}`,
body: `
`,
footer: `
Abbrechen
${UI.icon('paper-plane-tilt')} Anfrage senden
`,
});
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('req-send-btn').addEventListener('click', async () => {
const btn = document.getElementById('req-send-btn');
const nachricht = document.getElementById('req-nachricht').value.trim();
await UI.asyncButton(btn, async () => {
const result = await API.post('/playdate/request', {
to_dog_id: toDogId,
nachricht: nachricht || null,
});
UI.modal.close();
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
// Zum Chat navigieren
if (result.conversation_id) {
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
}
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: MEINE INSERATE
// ------------------------------------------------------------------
async function _renderListings(el) {
el.innerHTML = `${UI.icon('spinner')} Lädt…
`;
await _loadListings(el);
}
async function _loadListings(el) {
const target = el || document.getElementById('playdate-content');
if (!target) return;
if (_dogs.length === 0) {
target.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch kein Hund',
text: 'Lege zuerst einen Hund in deinem Profil an.',
action: `Hund anlegen `,
});
return;
}
// Listings für alle eigenen Hunde laden
const listings = {};
await Promise.all(_dogs.map(async dog => {
try {
const data = await API.get(`/playdate/my-listing/${dog.id}`);
listings[dog.id] = data;
} catch {
listings[dog.id] = null;
}
}));
target.innerHTML = `
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
`;
// Event-Delegation für alle Buttons
target.addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const dogId = parseInt(btn.dataset.dogId, 10);
const dog = _dogs.find(d => d.id === dogId);
if (action === 'edit') {
_showListingModal(dog, listings[dogId], async () => {
await _loadListings();
});
}
if (action === 'deactivate') {
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
try {
await API.del(`/playdate/listing/${dogId}`);
UI.toast.success('Inserat deaktiviert.');
await _loadListings();
} catch (err) {
UI.toast.error(err.message);
}
}
});
}
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
return `
${_dogAvatar(dog.foto_url, dog.name, 44)}
${UI.escape(dog.name)}
${dog.rasse ? `
${UI.escape(dog.rasse)}
` : ''}
${isAktiv ? 'Aktiv' : 'Inaktiv'}
${isAktiv ? `
${UI.icon('map-pin')}
${listing.ort_name ? UI.escape(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km
${listing.beschreibung ? `
${UI.escape(listing.beschreibung)}
` : ''}
` : `
Noch kein Inserat — trage dich ein, damit andere dich finden können.
`}
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
${isAktiv ? `
${UI.icon('x')} Deaktivieren
` : ''}
`;
}
function _showListingModal(dog, existing, onSaved) {
const formId = 'listing-form';
UI.modal.open({
title: `Inserat für ${dog.name}`,
body: `
Suchradius
5 km
10 km
25 km
50 km
Beschreibung (optional)
${UI.escape(existing?.beschreibung || '')}
`,
footer: `
Abbrechen
${UI.icon('floppy-disk')} Speichern
`,
});
// GPS-Button
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
const gpsBtn = document.getElementById('listing-gps-btn');
UI.setLoading(gpsBtn, true);
try {
const pos = await API.getLocation();
document.getElementById('listing-lat').value = pos.lat;
document.getElementById('listing-lon').value = pos.lon;
// Reverse-Geocoding für Ortsname
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
{ cache: 'no-store' }
);
const geoData = await rev.json();
const a = geoData.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) document.getElementById('listing-ort').value = ort;
} catch {}
UI.toast.success('Standort ermittelt.');
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(gpsBtn, false);
}
});
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('listing-save-btn').addEventListener('click', async () => {
const btn = document.getElementById('listing-save-btn');
const lat = parseFloat(document.getElementById('listing-lat').value);
const lon = parseFloat(document.getElementById('listing-lon').value);
const ort = document.getElementById('listing-ort').value.trim();
const rad = parseInt(document.getElementById('listing-radius').value, 10);
const desc = document.getElementById('listing-beschreibung').value.trim();
if (!lat || !lon) {
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
return;
}
await UI.asyncButton(btn, async () => {
await API.put('/playdate/listing', {
dog_id: dog.id,
lat,
lon,
ort_name: ort || null,
radius_km: rad,
beschreibung: desc || null,
});
UI.modal.close();
UI.toast.success('Inserat gespeichert!');
onSaved?.();
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: ANFRAGEN
// ------------------------------------------------------------------
async function _renderRequests(el) {
el.innerHTML = `${UI.icon('spinner')} Lädt…
`;
try {
const data = await API.get('/playdate/requests');
const incoming = data.incoming || [];
const outgoing = data.outgoing || [];
// Badge aktualisieren
const pendingCount = incoming.filter(r => r.status === 'pending').length;
const badge = document.getElementById('playdate-req-badge');
if (badge) {
badge.textContent = pendingCount;
badge.style.display = pendingCount > 0 ? '' : 'none';
}
if (incoming.length === 0 && outgoing.length === 0) {
el.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch keine Anfragen',
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
});
return;
}
el.innerHTML = `
${incoming.length > 0 ? `
Eingehende Anfragen
${incoming.map(r => _incomingCard(r)).join('')}
` : ''}
${outgoing.length > 0 ? `
Ausgehende Anfragen
${outgoing.map(r => _outgoingCard(r)).join('')}
` : ''}
`;
// Button-Events (Accept/Decline)
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const reqId = parseInt(btn.dataset.reqId, 10);
const status = btn.dataset.status;
await UI.asyncButton(btn, async () => {
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
if (status === 'accepted' && result.conversation_id) {
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
} else {
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
}
await _renderRequests(el);
}, { errorMsg: null });
});
});
// Chat-Buttons
el.querySelectorAll('.req-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
App.navigate('chat', true);
});
});
} catch (err) {
el.innerHTML = `${err.message}
`;
}
}
function _incomingCard(r) {
const isPending = r.status === 'pending';
return `
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
${UI.escape(r.from_dog_name)}
${r.from_dog_rasse ? UI.escape(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? UI.escape(r.alter) + ' · ' : ''}
von ${UI.escape(r.from_user_name)}
${_fmtDate(r.created_at)}
${_statusBadge(r.status)}
${r.nachricht ? `
"${UI.escape(r.nachricht)}"
` : ''}
${isPending ? `
${UI.icon('check')} Annehmen
${UI.icon('x')} Ablehnen
` : `
${r.status === 'accepted' ? `
${UI.icon('chat-circle-dots')} Zum Chat
` : ''}
`}
`;
}
function _outgoingCard(r) {
return `
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
${UI.escape(r.to_dog_name)}
${r.to_dog_rasse ? UI.escape(r.to_dog_rasse) + ' · ' : ''}
von ${UI.escape(r.to_user_name)}
${_fmtDate(r.created_at)}
${_statusBadge(r.status)}
${r.nachricht ? `
"${UI.escape(r.nachricht)}"
` : ''}
${r.status === 'accepted' ? `
${UI.icon('chat-circle-dots')} Chat öffnen
` : ''}
`;
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();