PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
958 lines
39 KiB
JavaScript
958 lines
39 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;
|
||
let _communityData = null; // [] listings from /adoption/community
|
||
let _myListings = null; // [] eigene Inserate
|
||
|
||
// ----------------------------------------------------------
|
||
// 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>
|
||
<button class="adp-tab" data-tab="community"
|
||
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('heart')} Weitervermittlung
|
||
</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;
|
||
}
|
||
}
|
||
|
||
async function _loadCommunity() {
|
||
const content = _container.querySelector('#adp-content');
|
||
if (content) content.innerHTML = UI.skeleton(4);
|
||
try {
|
||
const url = _lat && _lon
|
||
? `/adoption/community?lat=${_lat}&lon=${_lon}`
|
||
: '/adoption/community';
|
||
_communityData = await API.get(url);
|
||
if (_appState?.user) {
|
||
try {
|
||
_myListings = await API.get('/adoption/community/my');
|
||
} catch {
|
||
_myListings = [];
|
||
}
|
||
}
|
||
_renderCommunity(content);
|
||
} catch {
|
||
if (content) content.innerHTML = UI.emptyState({
|
||
icon: 'warning',
|
||
title: 'Weitervermittlungs-Inserate konnten nicht geladen werden',
|
||
text: 'Bitte versuche es erneut.',
|
||
});
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// INHALT RENDERN (je nach Tab)
|
||
// ----------------------------------------------------------
|
||
function _renderContent() {
|
||
const content = _container.querySelector('#adp-content');
|
||
if (!content) return;
|
||
|
||
if (_activeTab === 'community') {
|
||
_loadCommunity();
|
||
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 class="mb-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 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 class="flex-col-gap-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 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 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 class="flex-1-min">
|
||
<div style="font-weight:600;font-size:var(--text-sm);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${_esc(s.name)}
|
||
</div>
|
||
<div class="text-xs-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>
|
||
`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: WEITERVERMITTLUNG (Community)
|
||
// ------------------------------------------------------------------
|
||
function _renderCommunity(content) {
|
||
if (!content) return;
|
||
|
||
const listings = _communityData || [];
|
||
const isLoggedIn = !!_appState?.user;
|
||
|
||
const fabHtml = isLoggedIn ? `
|
||
<button id="adp-fab-create"
|
||
style="position:fixed;bottom:calc(var(--nav-height,64px) + var(--space-4));right:var(--space-4);
|
||
z-index:100;width:56px;height:56px;border-radius:50%;
|
||
background:var(--c-primary);color:#fff;border:none;cursor:pointer;
|
||
box-shadow:0 4px 16px rgba(0,0,0,0.2);
|
||
display:flex;align-items:center;justify-content:center;font-size:1.5rem"
|
||
title="Hund zur Vermittlung anbieten"
|
||
aria-label="Hund zur Vermittlung anbieten">
|
||
${UI.icon('plus')}
|
||
</button>
|
||
` : '';
|
||
|
||
if (!listings.length) {
|
||
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 class="mb-2">Noch keine Hunde zur Weitervermittlung</h3>
|
||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
|
||
Hier können Halter Hunde privat zur Weitervermittlung anbieten —
|
||
zum Beispiel bei Umzug, Krankheit oder Allergie.
|
||
</p>
|
||
${isLoggedIn ? `
|
||
<button class="btn btn-primary" id="adp-empty-create">
|
||
${UI.icon('plus')} Hund zur Vermittlung anbieten
|
||
</button>
|
||
` : `
|
||
<p class="text-sm-secondary">
|
||
Bitte anmelden, um ein Inserat zu erstellen.
|
||
</p>
|
||
`}
|
||
</div>
|
||
${fabHtml}
|
||
`;
|
||
content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal);
|
||
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
|
||
return;
|
||
}
|
||
|
||
// Eigene Inserate trennen
|
||
const myIds = new Set((_myListings || []).map(l => l.id));
|
||
|
||
content.innerHTML = `
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
||
${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung
|
||
</p>
|
||
<div class="adp-grid"
|
||
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--space-3)">
|
||
${listings.map(l => _communityCard(l)).join('')}
|
||
</div>
|
||
|
||
${isLoggedIn && _myListings && _myListings.length ? `
|
||
<div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
||
<h4 class="mb-3">Meine Inserate</h4>
|
||
<div class="flex-col-gap-2">
|
||
${_myListings.map(l => _myListingRow(l)).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${fabHtml}
|
||
`;
|
||
|
||
// Interest-Button Events
|
||
content.querySelectorAll('[data-adp-interest]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const id = btn.dataset.adpInterest;
|
||
const interested = btn.dataset.adpInterested === 'true';
|
||
_handleInterest(id, interested, btn);
|
||
});
|
||
});
|
||
|
||
// FAB
|
||
content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal);
|
||
|
||
// Meine Inserate: Status-Dropdown + Löschen
|
||
content.querySelectorAll('[data-adp-status-change]').forEach(sel => {
|
||
sel.addEventListener('change', async () => {
|
||
const id = sel.dataset.adpStatusChange;
|
||
try {
|
||
await API.patch(`/adoption/community/${id}`, { status: sel.value });
|
||
UI.toast.success('Status aktualisiert.');
|
||
_loadCommunity();
|
||
} catch {
|
||
UI.toast.error('Status konnte nicht aktualisiert werden.');
|
||
}
|
||
});
|
||
});
|
||
|
||
content.querySelectorAll('[data-adp-delete]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!window.confirm('Inserat wirklich löschen?')) return;
|
||
try {
|
||
await API.del(`/adoption/community/${btn.dataset.adpDelete}`);
|
||
UI.toast.success('Inserat gelöscht.');
|
||
_communityData = null;
|
||
_myListings = null;
|
||
_loadCommunity();
|
||
} catch {
|
||
UI.toast.error('Löschen fehlgeschlagen.');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _communityCard(l) {
|
||
const foto = l.foto_url
|
||
? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.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:2.5rem">🐾</div>'">`
|
||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
|
||
|
||
const isActive = !l.status || l.status === 'active';
|
||
const statusLabel = l.status === 'reserved' ? 'Reserviert'
|
||
: l.status === 'adopted' ? 'Vermittelt'
|
||
: '';
|
||
|
||
const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo'
|
||
: l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J'
|
||
: l.alter_kategorie === 'adult' ? 'Adult 2–8J'
|
||
: l.alter_kategorie === 'senior' ? 'Senior >8J'
|
||
: '';
|
||
|
||
const genderIcon = l.geschlecht === 'maennlich' ? '♂'
|
||
: l.geschlecht === 'weiblich' ? '♀'
|
||
: '';
|
||
|
||
const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : '';
|
||
const ort = [l.plz, l.ort].filter(Boolean).join(' ');
|
||
|
||
const interestBtn = l.user_interested
|
||
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
|
||
data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
|
||
✓ Bereits gemeldet
|
||
</button>`
|
||
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)"
|
||
data-adp-interest="${_esc(l.id)}" data-adp-interested="false"
|
||
${!isActive ? 'disabled' : ''}>
|
||
Interesse bekunden
|
||
</button>`;
|
||
|
||
return `
|
||
<div style="border-radius:var(--radius-md);overflow:hidden;
|
||
background:var(--c-bg-card,var(--c-surface-2));
|
||
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
||
display:flex;flex-direction:column;position:relative">
|
||
<!-- Foto -->
|
||
<div style="height:140px;overflow:hidden;background:var(--c-surface-3);position:relative">
|
||
${foto}
|
||
${!isActive ? `
|
||
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.45);
|
||
display:flex;align-items:center;justify-content:center">
|
||
<span style="color:#fff;font-weight:700;font-size:var(--text-sm);
|
||
background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
|
||
${_esc(statusLabel)}
|
||
</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<!-- Body -->
|
||
<div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)">
|
||
<div style="font-weight:600;font-size:var(--text-sm);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${_esc(l.name)}
|
||
</div>
|
||
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${_esc(l.rasse)}
|
||
</div>` : ''}
|
||
<!-- Badges -->
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||
${_esc(alterLabel)}
|
||
</span>` : ''}
|
||
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
|
||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
||
${genderIcon}
|
||
</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>
|
||
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
|
||
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
overflow:hidden;display:-webkit-box;
|
||
-webkit-line-clamp:2;-webkit-box-orient:vertical">
|
||
${_esc(l.beschreibung)}
|
||
</div>` : ''}
|
||
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
|
||
❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
|
||
</div>` : ''}
|
||
<div style="margin-top:auto;padding-top:var(--space-1)">
|
||
${interestBtn}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function _myListingRow(l) {
|
||
const statusOptions = [
|
||
{ value: 'active', label: 'Aktiv' },
|
||
{ value: 'reserved', label: 'Reserviert' },
|
||
{ value: 'adopted', label: 'Vermittelt' },
|
||
];
|
||
return `
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:var(--c-surface-2);border:1px solid var(--c-border)">
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:600;font-size:var(--text-sm);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${_esc(l.name)}
|
||
</div>
|
||
<div class="text-xs-secondary">
|
||
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
|
||
</div>
|
||
</div>
|
||
<select class="form-control" style="width:auto;font-size:var(--text-xs)"
|
||
data-adp-status-change="${_esc(l.id)}">
|
||
${statusOptions.map(o => `
|
||
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
|
||
`).join('')}
|
||
</select>
|
||
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
|
||
data-adp-delete="${_esc(l.id)}">
|
||
${UI.icon('trash')} Löschen
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// INTERESSE BEKUNDEN / ZURÜCKZIEHEN
|
||
// ------------------------------------------------------------------
|
||
async function _handleInterest(id, isInterested, btn) {
|
||
if (!_appState?.user) {
|
||
UI.toast.error('Bitte anmelden um Interesse zu bekunden.');
|
||
return;
|
||
}
|
||
|
||
if (isInterested) {
|
||
// Interesse zurückziehen
|
||
try {
|
||
btn.disabled = true;
|
||
await API.del(`/adoption/community/${id}/interest`);
|
||
UI.toast.success('Interesse zurückgezogen.');
|
||
_communityData = null;
|
||
_myListings = null;
|
||
_loadCommunity();
|
||
} catch {
|
||
UI.toast.error('Fehler beim Zurückziehen des Interesses.');
|
||
btn.disabled = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Interesse bekunden — Modal mit optionaler Nachricht
|
||
const body = `
|
||
<form id="adp-interest-form" class="flex-col-gap-3">
|
||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
Du kannst optional eine Nachricht an den Anbieter schicken.
|
||
</p>
|
||
<div class="form-group">
|
||
<label class="form-label">Nachricht (optional)</label>
|
||
<textarea class="form-control" name="nachricht" rows="3"
|
||
placeholder="Stell dich kurz vor und erzähle, warum dieser Hund zu dir passt…"></textarea>
|
||
</div>
|
||
</form>
|
||
`;
|
||
const footer = `
|
||
<div style="display:flex;gap:var(--space-2);width:100%">
|
||
<button type="submit" form="adp-interest-form" class="btn btn-primary flex-1" id="adp-interest-submit">
|
||
${UI.icon('heart')} Interesse bekunden
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||
</div>
|
||
`;
|
||
|
||
UI.modal.open({ title: 'Interesse bekunden', body, footer });
|
||
|
||
document.getElementById('adp-interest-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const submitBtn = document.getElementById('adp-interest-submit');
|
||
const fd = new FormData(e.target);
|
||
const payload = { nachricht: fd.get('nachricht') || null };
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||
try {
|
||
await API.post(`/adoption/community/${id}/interest`, payload);
|
||
UI.modal.close();
|
||
UI.toast.success('Interesse gemeldet!');
|
||
_communityData = null;
|
||
_myListings = null;
|
||
_loadCommunity();
|
||
} catch {
|
||
UI.toast.error('Fehler beim Melden des Interesses.');
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; }
|
||
}
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// INSERAT ERSTELLEN — Modal
|
||
// ------------------------------------------------------------------
|
||
function _openCreateModal() {
|
||
if (!_appState?.user) {
|
||
UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.');
|
||
return;
|
||
}
|
||
|
||
const body = `
|
||
<form id="adp-create-form" class="flex-col-gap-3">
|
||
<div class="form-group">
|
||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||
<input class="form-control" name="name" required placeholder="z.B. Bello">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Rasse (optional)</label>
|
||
<input class="form-control" name="rasse" placeholder="z.B. Labrador Mischling">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||
<div class="form-group">
|
||
<label class="form-label">Alter</label>
|
||
<select class="form-control" name="alter_kategorie">
|
||
<option value="">Unbekannt</option>
|
||
<option value="welpe">Welpe <6Mo</option>
|
||
<option value="jung">Jung 6Mo–2J</option>
|
||
<option value="adult">Adult 2–8J</option>
|
||
<option value="senior">Senior >8J</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Geschlecht</label>
|
||
<select class="form-control" name="geschlecht">
|
||
<option value="">Unbekannt</option>
|
||
<option value="maennlich">Männlich</option>
|
||
<option value="weiblich">Weiblich</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||
<div class="form-group">
|
||
<label class="form-label">PLZ</label>
|
||
<input class="form-control" name="plz" inputmode="numeric" maxlength="5"
|
||
placeholder="z.B. 80331" value="${_esc(_lat ? '' : '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ort</label>
|
||
<input class="form-control" name="ort" placeholder="z.B. München">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Beschreibung <span class="text-danger">*</span></label>
|
||
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
|
||
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
|
||
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Hintergrund (optional)</label>
|
||
<textarea class="form-control" name="hintergrund" rows="2"
|
||
placeholder="Warum suchst du ein neues Zuhause? (Krankheit, Umzug, Allergie…)"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Foto (optional)</label>
|
||
<input class="form-control" type="file" name="foto" accept="image/*" id="adp-create-foto">
|
||
</div>
|
||
</form>
|
||
`;
|
||
|
||
const footer = `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
<button type="submit" form="adp-create-form" class="btn btn-primary w-full" id="adp-create-submit">
|
||
${UI.icon('plus')} Inserat erstellen
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||
</div>
|
||
`;
|
||
|
||
UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer });
|
||
|
||
document.getElementById('adp-create-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const submitBtn = document.getElementById('adp-create-submit');
|
||
const fd = new FormData(e.target);
|
||
|
||
// Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text)
|
||
const beschreibung = (fd.get('beschreibung') || '').trim();
|
||
if (beschreibung.length < 80) {
|
||
UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.');
|
||
return;
|
||
}
|
||
|
||
// FormData für multipart aufbauen
|
||
const postData = new FormData();
|
||
postData.append('name', fd.get('name') || '');
|
||
postData.append('rasse', fd.get('rasse') || '');
|
||
postData.append('alter_kategorie', fd.get('alter_kategorie') || '');
|
||
postData.append('geschlecht', fd.get('geschlecht') || '');
|
||
postData.append('plz', fd.get('plz') || '');
|
||
postData.append('ort', fd.get('ort') || '');
|
||
postData.append('beschreibung', beschreibung);
|
||
postData.append('hintergrund', fd.get('hintergrund') || '');
|
||
if (_lat) postData.append('lat', _lat);
|
||
if (_lon) postData.append('lon', _lon);
|
||
const fotoFile = document.getElementById('adp-create-foto')?.files?.[0];
|
||
if (fotoFile) postData.append('foto', fotoFile);
|
||
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
|
||
try {
|
||
await API.upload('/adoption/community', postData);
|
||
UI.modal.close();
|
||
UI.toast.success('Inserat erstellt!');
|
||
_communityData = null;
|
||
_myListings = null;
|
||
_loadCommunity();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.');
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 };
|
||
|
||
})();
|