banyaro/backend/static/js/pages/adoption.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
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).
2026-05-27 07:11:27 +02:00

958 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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=&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 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=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</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 6Mo2J'
: l.alter_kategorie === 'adult' ? 'Adult 28J'
: 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 &lt;6Mo</option>
<option value="jung">Jung 6Mo2J</option>
<option value="adult">Adult 28J</option>
<option value="senior">Senior &gt;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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();