banyaro/backend/static/js/pages/adoption.js
rene 742ad189e8 Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)
- 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
2026-05-02 09:29:48 +02:00

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=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();