- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
483 lines
20 KiB
JavaScript
483 lines
20 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Adoption (Tierheim-Hunde in der Nähe)
|
|
Seiten-Modul: Hunde aus deutschen Tierheimen finden.
|
|
============================================================ */
|
|
|
|
window.Page_adoption = (() => {
|
|
|
|
// ----------------------------------------------------------
|
|
// MODUL-STATE
|
|
// ----------------------------------------------------------
|
|
let _container = null;
|
|
let _appState = null;
|
|
let _lat = null;
|
|
let _lon = null;
|
|
let _radius = 50;
|
|
let _rasseFilter = '';
|
|
let _activeTab = 'hunde';
|
|
let _data = null; // { animals, shelters, has_petfinder }
|
|
let _loading = false;
|
|
|
|
// ----------------------------------------------------------
|
|
// INIT
|
|
// ----------------------------------------------------------
|
|
async function init(container, appState) {
|
|
_container = container;
|
|
_appState = appState;
|
|
_render();
|
|
// Standort automatisch versuchen
|
|
_tryAutoLocate();
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// REFRESH
|
|
// ----------------------------------------------------------
|
|
async function refresh() {
|
|
if (_lat && _lon) {
|
|
await _loadData();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// RENDER — Grundstruktur
|
|
// ----------------------------------------------------------
|
|
function _render() {
|
|
_container.innerHTML = `
|
|
<!-- Filter-Leiste -->
|
|
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3);align-items:center">
|
|
<select id="adp-radius" class="form-control" style="width:auto;min-width:110px">
|
|
<option value="10">10 km</option>
|
|
<option value="25">25 km</option>
|
|
<option value="50" selected>50 km</option>
|
|
<option value="100">100 km</option>
|
|
</select>
|
|
<input id="adp-rasse" class="form-control" type="text"
|
|
placeholder="Rasse filtern…"
|
|
style="flex:1;min-width:120px;max-width:220px"
|
|
value="${_esc(_rasseFilter)}">
|
|
<button class="btn btn-secondary" id="adp-btn-locate"
|
|
style="white-space:nowrap">
|
|
${UI.icon('map-pin')} Mein Standort
|
|
</button>
|
|
</div>
|
|
|
|
<!-- PLZ-Fallback (anfangs versteckt) -->
|
|
<div id="adp-plz-row" style="display:none;margin-bottom:var(--space-3)">
|
|
<div style="display:flex;gap:var(--space-2);align-items:center">
|
|
<input id="adp-plz" class="form-control" type="text"
|
|
inputmode="numeric" maxlength="5"
|
|
placeholder="PLZ eingeben (z.B. 80331)"
|
|
style="max-width:180px">
|
|
<button class="btn btn-primary" id="adp-btn-geocode">Suchen</button>
|
|
</div>
|
|
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">
|
|
Kein Standort verfügbar — PLZ als Ausgangspunkt eingeben.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div style="display:flex;gap:var(--space-1);margin-bottom:var(--space-4);
|
|
border-bottom:1px solid var(--c-border)">
|
|
<button class="adp-tab adp-tab--active" data-tab="hunde"
|
|
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
|
cursor:pointer;font-weight:600;color:var(--c-primary);
|
|
border-bottom:2px solid var(--c-primary);font-size:var(--text-sm)">
|
|
${UI.icon('paw-print')} Hunde
|
|
</button>
|
|
<button class="adp-tab" data-tab="tierheime"
|
|
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
|
cursor:pointer;color:var(--c-text-secondary);
|
|
border-bottom:2px solid transparent;font-size:var(--text-sm)">
|
|
${UI.icon('house-line')} Tierheime
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Inhalt -->
|
|
<div id="adp-content">
|
|
${UI.skeleton(4)}
|
|
</div>
|
|
`;
|
|
|
|
// Events
|
|
_container.querySelector('#adp-radius')
|
|
?.addEventListener('change', e => {
|
|
_radius = parseInt(e.target.value);
|
|
if (_lat && _lon) _loadData();
|
|
});
|
|
|
|
_container.querySelector('#adp-rasse')
|
|
?.addEventListener('input', e => {
|
|
_rasseFilter = e.target.value.trim().toLowerCase();
|
|
_renderContent();
|
|
});
|
|
|
|
_container.querySelector('#adp-btn-locate')
|
|
?.addEventListener('click', _locateUser);
|
|
|
|
_container.querySelector('#adp-btn-geocode')
|
|
?.addEventListener('click', _geocodePLZ);
|
|
|
|
_container.querySelector('#adp-plz')
|
|
?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') _geocodePLZ();
|
|
});
|
|
|
|
_container.querySelectorAll('.adp-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
_activeTab = btn.dataset.tab;
|
|
_container.querySelectorAll('.adp-tab').forEach(b => {
|
|
const isActive = b.dataset.tab === _activeTab;
|
|
b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)';
|
|
b.style.fontWeight = isActive ? '600' : 'normal';
|
|
b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent';
|
|
});
|
|
_renderContent();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// STANDORT AUTOMATISCH ERMITTELN
|
|
// ----------------------------------------------------------
|
|
async function _tryAutoLocate() {
|
|
try {
|
|
const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 });
|
|
_lat = pos.lat;
|
|
_lon = pos.lon;
|
|
await _loadData();
|
|
} catch {
|
|
// Standort nicht verfügbar → PLZ-Eingabe zeigen
|
|
document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important');
|
|
document.getElementById('adp-plz-row').style.display = 'flex';
|
|
_showNoLocation();
|
|
}
|
|
}
|
|
|
|
async function _locateUser() {
|
|
const btn = _container.querySelector('#adp-btn-locate');
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
const pos = await API.getLocation({ timeout: 10000 });
|
|
_lat = pos.lat;
|
|
_lon = pos.lon;
|
|
document.getElementById('adp-plz-row').style.display = 'none';
|
|
await _loadData();
|
|
} catch {
|
|
UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.');
|
|
document.getElementById('adp-plz-row').style.display = 'flex';
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function _geocodePLZ() {
|
|
const plz = (_container.querySelector('#adp-plz')?.value || '').trim();
|
|
if (!plz) return;
|
|
const btn = _container.querySelector('#adp-btn-geocode');
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`);
|
|
if (geo.lat && geo.lon) {
|
|
_lat = geo.lat;
|
|
_lon = geo.lon;
|
|
await _loadData();
|
|
} else {
|
|
UI.toast.error(`PLZ "${plz}" nicht gefunden.`);
|
|
}
|
|
} catch {
|
|
UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// DATEN LADEN
|
|
// ----------------------------------------------------------
|
|
async function _loadData() {
|
|
if (_loading || !_lat || !_lon) return;
|
|
_loading = true;
|
|
const content = _container.querySelector('#adp-content');
|
|
if (content) content.innerHTML = UI.skeleton(4);
|
|
try {
|
|
_data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`);
|
|
_renderContent();
|
|
} catch {
|
|
if (content) content.innerHTML = UI.emptyState({
|
|
icon: 'warning',
|
|
title: 'Daten konnten nicht geladen werden',
|
|
text: 'Bitte versuche es erneut.',
|
|
});
|
|
} finally {
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// INHALT RENDERN (je nach Tab)
|
|
// ----------------------------------------------------------
|
|
function _renderContent() {
|
|
const content = _container.querySelector('#adp-content');
|
|
if (!content) return;
|
|
if (!_data) { _showNoLocation(); return; }
|
|
|
|
if (_activeTab === 'hunde') _renderHunde(content);
|
|
else _renderTierheime(content);
|
|
}
|
|
|
|
function _showNoLocation() {
|
|
const content = _container.querySelector('#adp-content');
|
|
if (!content) return;
|
|
content.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
|
|
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
|
|
<h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
|
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
|
|
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
|
|
in deiner Umgebung zu finden.
|
|
</p>
|
|
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-primary">
|
|
${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: HUNDE
|
|
// ------------------------------------------------------------------
|
|
function _renderHunde(content) {
|
|
let animals = (_data?.animals || []);
|
|
|
|
// Rasse-Filter
|
|
if (_rasseFilter) {
|
|
animals = animals.filter(a =>
|
|
(a.rasse || '').toLowerCase().includes(_rasseFilter) ||
|
|
(a.name || '').toLowerCase().includes(_rasseFilter)
|
|
);
|
|
}
|
|
|
|
const hasPetFinder = _data?.has_petfinder;
|
|
const infoText = hasPetFinder
|
|
? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)`
|
|
: '';
|
|
|
|
if (!animals.length) {
|
|
content.innerHTML = `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
|
|
</p>
|
|
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
|
|
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-primary">
|
|
${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen
|
|
</a>
|
|
<a href="https://www.tierschutz.com/tierheimsuche/"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-secondary">
|
|
${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com
|
|
</a>
|
|
</div>
|
|
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-4)">
|
|
Tipp: Schau auch im Tab „Tierheime" nach lokalen Tierheimen direkt.
|
|
</p>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = `
|
|
${infoText ? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">${infoText}</p>` : ''}
|
|
<div class="adp-grid"
|
|
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:var(--space-3)">
|
|
${animals.map(a => _animalCard(a)).join('')}
|
|
</div>
|
|
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
|
Mehr Hunde finden:
|
|
</p>
|
|
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-secondary" style="font-size:var(--text-sm)">
|
|
${UI.icon('arrow-square-out')} Tierheimhelden.de — alle Hunde
|
|
</a>
|
|
</div>
|
|
`;
|
|
|
|
// Klick-Events
|
|
content.querySelectorAll('[data-adp-url]').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer');
|
|
});
|
|
});
|
|
}
|
|
|
|
function _animalCard(a) {
|
|
const foto = a.foto_url
|
|
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
|
|
style="width:100%;height:100%;object-fit:cover"
|
|
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</div>'">`
|
|
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
|
|
|
|
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
|
|
const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : '';
|
|
const rasseTxt = a.rasse || '';
|
|
const tierheim = a.tierheim || '';
|
|
|
|
return `
|
|
<div data-adp-url="${_esc(a.adoptions_url)}"
|
|
style="border-radius:var(--radius-md);overflow:hidden;
|
|
background:var(--c-surface-2);cursor:pointer;
|
|
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
|
transition:transform .15s,box-shadow .15s"
|
|
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
|
|
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
|
|
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
|
|
${foto}
|
|
</div>
|
|
<div style="padding:var(--space-2) var(--space-2) var(--space-3)">
|
|
<div style="font-weight:600;font-size:var(--text-sm);
|
|
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
${_esc(a.name)}
|
|
</div>
|
|
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
${_esc(rasseTxt)}
|
|
</div>` : ''}
|
|
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
|
|
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
|
|
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
|
${_esc(alterTxt)}
|
|
</span>` : ''}
|
|
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
|
|
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
|
${a.geschlecht === 'männlich' ? '♂' : '♀'}
|
|
</span>` : ''}
|
|
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
|
|
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
|
|
${_esc(distTxt)}
|
|
</span>` : ''}
|
|
</div>
|
|
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
|
|
${UI.icon('house-line')} ${_esc(tierheim)}
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TAB: TIERHEIME
|
|
// ------------------------------------------------------------------
|
|
function _renderTierheime(content) {
|
|
const shelters = _data?.shelters || [];
|
|
|
|
if (!shelters.length) {
|
|
content.innerHTML = `
|
|
<div style="text-align:center;padding:var(--space-6) var(--space-4)">
|
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
Keine Tierheime im Umkreis von ${_radius} km gefunden.
|
|
</p>
|
|
<a href="https://www.tierheimhelden.de"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-primary">
|
|
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
|
</a>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
|
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
|
|
</p>
|
|
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
|
${shelters.map(s => _shelterRow(s)).join('')}
|
|
</div>
|
|
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
|
Noch mehr Tierheime:
|
|
</p>
|
|
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
|
<a href="https://www.tierheimhelden.de"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
|
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
|
</a>
|
|
<a href="https://www.tierschutz.com/tierheimsuche/"
|
|
target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
|
${UI.icon('magnifying-glass')} tierschutz.com
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function _shelterRow(s) {
|
|
return `
|
|
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
|
|
style="display:flex;align-items:center;gap:var(--space-3);
|
|
padding:var(--space-3);border-radius:var(--radius-md);
|
|
background:var(--c-surface-2);text-decoration:none;color:inherit;
|
|
border:1px solid var(--c-border);
|
|
transition:background .15s"
|
|
onmouseenter="this.style.background='var(--c-surface-3)'"
|
|
onmouseleave="this.style.background='var(--c-surface-2)'">
|
|
<div style="width:40px;height:40px;border-radius:50%;
|
|
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:1.2rem">
|
|
🏠
|
|
</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-weight:600;font-size:var(--text-sm);
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
${_esc(s.name)}
|
|
</div>
|
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
${_esc(s.plz)} ${_esc(s.stadt)}
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
|
|
<span style="font-size:var(--text-xs);font-weight:600;
|
|
color:var(--c-primary);background:var(--c-primary-light,#ede9fe);
|
|
border-radius:999px;padding:2px 8px">
|
|
${s.distanz_km} km
|
|
</span>
|
|
<span style="font-size:10px;color:var(--c-text-muted)">Hunde ansehen ${UI.icon('arrow-right')}</span>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// HILFSFUNKTIONEN
|
|
// ----------------------------------------------------------
|
|
function _formatAlter(jahre) {
|
|
if (jahre < 0.5) return 'Welpe';
|
|
if (jahre < 1) return 'Jungtier';
|
|
if (jahre < 2) return `${Math.round(jahre)} Jahr`;
|
|
if (jahre < 10) return `${Math.round(jahre)} Jahre`;
|
|
return 'Senior';
|
|
}
|
|
|
|
function _esc(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// PUBLIC API
|
|
// ----------------------------------------------------------
|
|
return { init, refresh };
|
|
|
|
})();
|