Release v1.3.0

This commit is contained in:
rene 2026-05-03 11:09:43 +02:00
commit 15e2446ea7
68 changed files with 16373 additions and 465 deletions

View file

@ -6,69 +6,84 @@
const API = (() => {
// ----------------------------------------------------------
// Request-Deduplication: gleiche GET-URL nur einmal in-flight
// ----------------------------------------------------------
const _inflight = new Map();
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
async function _request(method, path, body = null, options = {}) {
async function _doRequest(method, path, body, attempt) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
credentials: 'include',
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
delete config.headers['Content-Type']; // Browser setzt multipart/form-data
delete config.headers['Content-Type'];
config.body = body;
}
// JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
if (token) config.headers['Authorization'] = `Bearer ${token}`;
let response;
try {
response = await fetch(`/api${path}`, config);
} catch (err) {
const offlineMsg = 'Kein Internet — du bist offline.';
if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
throw new APIError(offlineMsg, 0, 'network');
} catch {
// Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
if (method === 'GET' && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
const msg = 'Kein Internet — du bist offline.';
if (window.UI?.toast) UI.toast.warning(msg, 4000);
throw new APIError(msg, 0, 'network');
}
// 204 No Content
if (response.status === 204) return null;
let data;
try {
data = await response.json();
} catch {
data = null;
}
try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
// SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
const isOffline = response.status === 503 && message.startsWith('Offline');
if (isOffline && window.UI && UI.toast) {
UI.toast.warning('Kein Internet — du bist offline.', 4000);
const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
return _doRequest(method, path, body, attempt + 1);
}
throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
}
// SW hat die Anfrage in die Offline-Queue eingereiht
if (data?._queued) {
if (typeof UI !== 'undefined' && UI.toast) {
if (typeof UI !== 'undefined' && UI.toast)
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
}
return data;
}
return data;
}
async function _request(method, path, body = null) {
// GET-Deduplication: laufende identische Anfragen zusammenfassen
if (method === 'GET') {
if (_inflight.has(path)) return _inflight.get(path);
const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
_inflight.set(path, promise);
return promise;
}
return _doRequest(method, path, body, 0);
}
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
@ -195,6 +210,17 @@ const API = (() => {
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
myFavorite() { return get('/tieraerzte/my-favorite'); },
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
};
// ----------------------------------------------------------
// GESUNDHEITSDOKUMENTE
// ----------------------------------------------------------
const healthDocs = {
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
upload(formData) { return upload('/health-docs/upload', formData); },
delete(id) { return del(`/health-docs/${id}`); },
};
// ----------------------------------------------------------
@ -415,8 +441,9 @@ const API = (() => {
// WETTER
// ----------------------------------------------------------
const weather = {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
@ -712,7 +739,7 @@ const API = (() => {
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
breeder, litters, breederPhotos, zuchthunde, zuchtKi,

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
const App = (() => {
@ -70,6 +70,12 @@ const App = (() => {
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
};
// ----------------------------------------------------------
@ -85,6 +91,7 @@ const App = (() => {
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
};
// ----------------------------------------------------------
@ -92,6 +99,7 @@ const App = (() => {
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
if (window.Worlds?._visible) window.Worlds.hide();
// Aktive Seite ausblenden
document.querySelector('.page.active')?.classList.remove('active');
@ -564,7 +572,7 @@ const App = (() => {
banner.style.display = 'flex';
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
await API.post('/auth/resend-verification', { email: state.user.email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
}, { once: true });
@ -846,6 +854,9 @@ const App = (() => {
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
if (window.Worlds) window.Worlds.init(state);
}
async function _handleInvite(token) {
@ -919,6 +930,8 @@ const App = (() => {
})();
window.App = App; // Worlds kann App.navigate() aufrufen
// App starten
document.addEventListener('DOMContentLoaded', () => {
App.init();

View file

@ -14,11 +14,12 @@ window.Page_admin = (() => {
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
@ -47,6 +48,9 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Tabs -->
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
@ -72,9 +76,68 @@ window.Page_admin = (() => {
});
});
_renderActionItems();
_renderTab();
}
async function _renderActionItems() {
const el = _container.querySelector('#adm-action-items');
if (!el) return;
let d;
try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
];
const open = items.filter(i => d[i.key] > 0);
const usersToday = d.users_today || 0;
el.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${open.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: open.map(i => `
<button data-action-tab="${i.tab}"
style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
${UI.icon(i.icon)} ${i.label}
<span style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">
${d[i.key]}
</span>
</button>`).join('')
}
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
</span>
</div>`;
el.querySelectorAll('[data-action-tab]').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.actionTab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
}
async function _renderTab() {
const el = _container.querySelector('#adm-content');
if (!el) return;
@ -93,6 +156,7 @@ window.Page_admin = (() => {
case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -1396,6 +1460,43 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
// TAB: MODERATION
// ------------------------------------------------------------------
function _ageLabel(createdAt) {
if (!createdAt) return '';
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
const overdue = h >= 24;
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
margin-left:6px;${overdue
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
${overdue ? '⚠️ ' : ''}${label}
</span>`;
}
function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return `
<details style="margin-bottom:var(--space-4)">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
border-top:1px solid var(--c-border)">
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</summary>
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
${items.map(item => `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${renderItem(item)}
</div>`).join('')}
</div>
</details>`;
}
async function _renderModeration(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -1410,12 +1511,52 @@ window.Page_admin = (() => {
async function _loadModeration(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const [zuchter, fotos] = await Promise.all([
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
API.get('/moderation/reports').catch(() => []),
API.get('/moderation/poi-edits').catch(() => []),
]);
const zuchterPending = zuchter.filter(z => !z.verified);
const zuchterDone = zuchter.filter(z => z.verified);
const fotosPending = fotos.filter(f => f.status === 'pending');
const fotosDone = fotos.filter(f => f.status !== 'pending');
const reportsPending = reports.filter(r => !r.resolved);
const reportsDone = reports.filter(r => r.resolved);
const poiPending = poiEdits.filter(e => e.status === 'pending');
const poiDone = poiEdits.filter(e => e.status !== 'pending');
let html = '';
const modItems = [
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
].filter(i => i.count > 0);
let html = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${modItems.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: modItems.map(i => `
<span style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
${UI.icon(i.icon)} ${i.label}
<strong style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
</span>`).join('')
}
</div>`;
// --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1423,23 +1564,24 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
</h3>`;
if (!zuchter.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
if (!zuchterPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
} else {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody>
${zuchter.map((z, i) => `
${zuchterPending.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
<td class="adm-td">${_ageLabel(z.created_at)}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
@ -1448,6 +1590,10 @@ window.Page_admin = (() => {
</tr>`).join('')}
</tbody></table></div></div>`;
}
// Züchter-History
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
// --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1455,19 +1601,20 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
</h3>`;
if (!fotos.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
if (!fotosPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
${fotos.map(f => `
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
${fotosPending.map(f => `
<div class="card" style="padding:var(--space-4)">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)">
@ -1480,6 +1627,111 @@ window.Page_admin = (() => {
</div>`;
}
// Fotos-History
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
// --- Forum-Meldungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-4) 0 var(--space-3)">
Forum-Meldungen
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${reportsPending.length}
</span>
</h3>`;
if (!reportsPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
} else {
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
${reportsPending.map(r => `
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
${_ageLabel(r.created_at)}
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
</div>
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
${UI.icon('check')}
</button>
</div>
</div>`).join('')}
</div>`;
}
// Meldungen-History
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
// --- POI-Korrekturen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-2) 0 var(--space-3)">
POI-Korrekturen
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${poiPending.length}
</span>
</h3>`;
if (!poiPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden POI-Korrekturen.</p>`;
} else {
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Ort</th>
<th class="adm-th">Feld</th>
<th class="adm-th">Alt</th>
<th class="adm-th">Neu</th>
<th class="adm-th">Von</th>
<th class="adm-th">Alter</th>
<th class="adm-th"></th>
</tr></thead>
<tbody>
${poiPending.map((e, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
<td class="adm-td"><code style="font-size:var(--text-xs)">${_esc(e.field)}</code></td>
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
<td class="adm-td">${_ageLabel(e.created_at)}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
${UI.icon('check')}
</button>
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" style="color:var(--c-danger)">
${UI.icon('x')}
</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div></div>`;
}
// POI-History
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
<code style="font-size:var(--text-xs)">${_esc(e.field)}</code>:
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span>
${_esc(e.new_value||'—')} ·
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
el.innerHTML = html;
// Züchter freigeben
@ -1518,6 +1770,41 @@ window.Page_admin = (() => {
await _loadModeration(el);
});
});
// Forum-Meldung erledigen
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur freigeben
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
UI.toast.success('Korrektur übernommen.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur ablehnen
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
UI.toast.success('Korrektur abgelehnt.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
@ -2132,8 +2419,10 @@ window.Page_admin = (() => {
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
${log.map((l, i) => `
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
@ -2147,6 +2436,28 @@ window.Page_admin = (() => {
</div>
`;
// Log-Zeile: Mail-Inhalt anzeigen
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
row.addEventListener('click', () => {
const l = log[Number(row.dataset.logIdx)];
if (!l) return;
UI.modal.open({
title: _esc(l.subject),
body: `
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
<strong>An:</strong> ${_esc(l.recipient)} &nbsp;·&nbsp;
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app &nbsp;·&nbsp;
${(l.sent_at||'').slice(0,16).replace('T',' ')}
</div>
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);max-height:60vh;overflow-y:auto;
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
});
});
// Vorlage in Compose laden
function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id);
@ -2375,6 +2686,129 @@ window.Page_admin = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending' ? `${UI.icon('clock')} Neu`
: s==='reviewing' ? `${UI.icon('magnifying-glass')} In Prüfung`
: s==='accepted' ? `${UI.icon('check-circle')} Angenommen`
: s==='rejected' ? `${UI.icon('x')} Abgelehnt`
: 'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}> Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}> Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}> Abgelehnt</option>
</select>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };

View file

@ -0,0 +1,958 @@
/* ============================================================
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 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>
`;
}
// ------------------------------------------------------------------
// 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 style="margin-bottom:var(--space-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 style="font-size:var(--text-sm);color:var(--c-text-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 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
<div style="display:flex;flex-direction:column;gap:var(--space-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 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(l.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-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" style="display:flex;flex-direction:column;gap:var(--space-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" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-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 style="color:var(--c-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" style="width:100%" 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 };
})();

View file

@ -187,16 +187,14 @@ window.Page_dog_profile = (() => {
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
</button>` : ''}
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis
</button>
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>` : ''}
</div>
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
Hundepass
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>` : ''}
@ -209,7 +207,8 @@ window.Page_dog_profile = (() => {
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600">Sitter-Zugang</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gib einem Freund temporären Schreibzugang für diesen Hund
Gib einem Freund temporären Schreibzugang für diesen Hund.
Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen.
</div>
</div>
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade</div>
@ -257,14 +256,14 @@ window.Page_dog_profile = (() => {
_showChipEdit(dog);
});
document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => {
_showAusweisModal(dog.id);
});
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
_showShareModal(dog);
});
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
_showPassportModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -745,13 +744,7 @@ window.Page_dog_profile = (() => {
// AUSWEIS
// ----------------------------------------------------------
function _showAusweisModal(dogId) {
UI.modal.open({
title: 'Heimtierausweis',
body: `<iframe src="/ausweis/${dogId}" class="ausweis-frame" title="Heimtierausweis"></iframe>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a href="/ausweis/${dogId}" target="_blank" class="btn btn-ghost">${UI.icon('printer')} Drucken</a>`,
size: 'fullscreen',
});
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
}
// ----------------------------------------------------------
@ -996,7 +989,7 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
@ -1007,6 +1000,16 @@ window.Page_dog_profile = (() => {
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
style="margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Rasse erkennen
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" style="display:none">
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
</div>
</div>
@ -1086,6 +1089,9 @@ window.Page_dog_profile = (() => {
});
}
// Rassen-Erkennung per KI
_bindRasseErkennung();
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
@ -1171,6 +1177,152 @@ window.Page_dog_profile = (() => {
});
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Formular)
// ----------------------------------------------------------
function _bindRasseErkennung() {
const btn = document.getElementById('dp-rasse-erkennen-btn');
const fileInput = document.getElementById('dp-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast.error('Bild zu groß (max. 5 MB).');
return;
}
const origLabel = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origLabel;
_showRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origLabel;
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
}
});
}
function _showRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Diese wählen
</button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${_esc(r.wiki_slug)}">
Im Wiki
</button>` : ''}
</div>
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('dp-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
btn.addEventListener('click', () => {
const rasse = btn.dataset.rasse;
const rasseInput = document.getElementById('dp-rasse-input');
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
if (rasseInput) {
rasseInput.value = rasse;
rasseInput.dispatchEvent(new Event('input'));
}
UI.modal.close();
UI.toast.success(`Rasse "${rasse}" übernommen.`);
});
});
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
App.navigate('wiki');
setTimeout(() => {
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
Page_wiki._openBreedDetail(btn.dataset.slug);
}
}, 400);
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
@ -1196,6 +1348,444 @@ window.Page_dog_profile = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HUNDEPASS
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis öffnen
</a>
<button class="btn btn-secondary" id="pp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link teilen
</button>
<a class="btn btn-primary" id="pp-pdf-btn"
href="/api/passport/${dog.id}/pdf" target="_blank" download>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
PDF
</a>
</div>`,
size: 'large',
});
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
_createPassportShare(dog);
});
await _loadPassportBody(dog);
}
async function _loadPassportBody(dog) {
const wrap = document.getElementById('pp-body');
if (!wrap) return;
let data;
try {
data = await API.get(`/passport/${dog.id}`);
} catch (e) {
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
return;
}
const _fmt = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
const meta = data.meta || {};
const vaccs = data.vaccinations || [];
const meds = data.medications || [];
wrap.innerHTML = `
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Gesundheits-Info
</span>
<button class="btn btn-secondary btn-sm" id="pp-meta-edit-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
Bearbeiten
</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
</div>
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
</div>
</div>
</div>
${meta.besonderheiten ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${_esc(meta.besonderheiten)}
</div>
</div>` : ''}
</div>
<!-- Impfungen -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
Impfungen
</span>
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
</div>
<div id="pp-vacc-list">
${vaccs.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Impfungen eingetragen.<br>Klicke auf + Eintragen" um loszulegen.</p>
</div>`
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
<!-- Medikamente -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
Medikamente
</span>
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
</div>
<div id="pp-med-list">
${meds.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Medikamente eingetragen.<br>Klicke auf + Eintragen" um loszulegen.</p>
</div>`
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
`;
// Meta bearbeiten
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
});
// Impfung hinzufügen
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
_addVaccination(dog, () => _loadPassportBody(dog));
});
// Impfung löschen
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Impfung wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
// Medikament hinzufügen
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
_addMedication(dog, () => _loadPassportBody(dog));
});
// Medikament löschen
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Medikament wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
}
function _editPassportMeta(dog, current, onSave) {
UI.modal.open({
title: 'Gesundheits-Info bearbeiten',
body: `
<div class="form-group">
<label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text"
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
</div>
<div class="form-group">
<label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2"
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2"
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
</div>`,
});
document.getElementById('pp-meta-save').addEventListener('click', async () => {
const btn = document.getElementById('pp-meta-save');
UI.setLoading(btn, true);
try {
await API.put(`/passport/${dog.id}/meta`, {
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
allergien: document.getElementById('pp-meta-al').value.trim() || null,
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Gesundheits-Info gespeichert.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addVaccination(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Impfung eintragen',
body: `
<div class="form-group">
<label class="form-label">Krankheit *</label>
<input id="pp-vacc-krankheit" class="form-control" type="text"
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
<datalist id="pp-vacc-list">
<option value="Staupe">
<option value="Parvovirose">
<option value="Hepatitis (HCC)">
<option value="Leptospirose">
<option value="Tollwut">
<option value="Kennel-Husten (Bordetella)">
<option value="Borreliose">
<option value="DHPP (Kombi)">
</datalist>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Nächste fällig</label>
<input id="pp-vacc-naechste" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
</div>
<div class="form-group">
<label class="form-label">Charge-Nr.</label>
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
</div>`,
});
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
const datum = document.getElementById('pp-vacc-datum').value;
if (!krankheit || !datum) {
UI.toast.warning('Bitte Krankheit und Datum angeben.');
return;
}
const btn = document.getElementById('pp-vacc-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/vaccinations`, {
krankheit,
datum,
naechste: document.getElementById('pp-vacc-naechste').value || null,
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Impfung eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addMedication(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Medikament eintragen',
body: `
<div class="form-group">
<label class="form-label">Medikament *</label>
<input id="pp-med-name" class="form-control" type="text"
placeholder="z. B. Frontline, Milbemax, Onsior">
</div>
<div class="form-group">
<label class="form-label">Dosierung</label>
<input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
<input id="pp-med-bis" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input id="pp-med-notiz" class="form-control" type="text"
placeholder="z. B. nach dem Fressen geben">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
</div>`,
});
document.getElementById('pp-med-save').addEventListener('click', async () => {
const name = document.getElementById('pp-med-name').value.trim();
if (!name) {
UI.toast.warning('Bitte einen Namen angeben.');
return;
}
const btn = document.getElementById('pp-med-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/medications`, {
name,
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
von: document.getElementById('pp-med-von').value || null,
bis: document.getElementById('pp-med-bis').value || null,
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Medikament eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
async function _createPassportShare(dog) {
const btn = document.getElementById('pp-share-btn');
if (btn) UI.setLoading(btn, true);
try {
const res = await API.post(`/passport/${dog.id}/share`, {});
const url = `${location.origin}${res.url}`;
if (btn) UI.setLoading(btn, false);
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
const shareWrap = document.createElement('div');
shareWrap.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
</p>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly
value="${_esc(url)}" style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
</p>`;
UI.modal.open({
title: 'Hundepass-Link teilen',
body: shareWrap.innerHTML,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(url).catch(() => {});
UI.toast.success('Link kopiert!');
});
} catch (e) {
if (btn) UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -0,0 +1,828 @@
/* ============================================================
BAN YARO Ausgaben-Tracker
Tabs: Übersicht | Einträge | Statistik
============================================================ */
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
// Cache
let _summary = null;
let _entries = [];
let _statsData = null;
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
{ id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
];
const KATEGORIEN = [
{ id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' },
{ id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' },
{ id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' },
{ id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' },
{ id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' },
{ id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' },
];
function _kat(id) {
return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' };
}
// ----------------------------------------------------------
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_summary = null;
_entries = [];
_statsData = null;
_render();
}
async function refresh() {
_summary = null;
_entries = [];
_statsData = null;
await _renderTab();
}
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
</button>
`;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.tab;
_container.querySelectorAll('#exp-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
_container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null));
_renderTab();
}
// ----------------------------------------------------------
// TAB ROUTER
// ----------------------------------------------------------
async function _renderTab() {
const el = _container.querySelector('#exp-content');
if (!el) return;
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
try {
switch (_tab) {
case 'uebersicht': await _renderUebersicht(el); break;
case 'eintraege': await _renderEintraege(el); break;
case 'dauerauftraege': await _renderDauerauftraege(el); break;
case 'statistik': await _renderStatistik(el); break;
}
} catch (e) {
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
}
}
// ----------------------------------------------------------
// TAB: ÜBERSICHT
// ----------------------------------------------------------
async function _renderUebersicht(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
const s = _summary;
// Vormonatsvergleich berechnen
const letzteMonat = await _getLetzteMonateData();
const trendHtml = _trendHtml(letzteMonat);
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
return `
<div class="exp-kachel">
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
<div class="exp-kachel-label">${k.label}</div>
</div>`;
}).join('');
const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : '';
el.innerHTML = `
<div class="exp-hero-card">
<div class="exp-hero-label">Dieser Monat</div>
<div class="exp-hero-betrag">${_fmt(s.gesamt_monat)}</div>
<div class="exp-hero-meta">
${UI.icon('calendar')} Dieses Jahr: <strong>${_fmt(s.gesamt_jahr)}</strong>
${trendHtml}
</div>
</div>
<div class="exp-kachel-grid">${kacheln}</div>
${verlauf}
<div style="height:80px"></div>
`;
}
async function _getLetzteMonateData() {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const monatMap = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7);
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
return Object.entries(monatMap)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 6)
.reverse();
}
function _trendHtml(data) {
// Vergleich: aktueller Monat vs. Vormonat
if (data.length < 2) return '';
const aktuell = data[data.length - 1][1];
const vormonat = data[data.length - 2][1];
if (!vormonat) return '';
const diff = aktuell - vormonat;
const pct = Math.round(Math.abs(diff / vormonat) * 100);
if (pct === 0) return '';
const pfeil = diff > 0
? `<span class="exp-trend exp-trend--up">${UI.icon('arrow-up')} +${pct}% ggü. Vormonat</span>`
: `<span class="exp-trend exp-trend--down">${UI.icon('arrow-down')} ${pct}% ggü. Vormonat</span>`;
return pfeil;
}
function _vergleichHtml(data) {
if (!data.length) return '';
const max = Math.max(...data.map(d => d[1]), 1);
const balken = data.map(([monat, summe]) => {
const pct = Math.round((summe / max) * 100);
const [y, m] = monat.split('-');
const label = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'short' });
return `
<div class="exp-bar-item">
<div class="exp-bar-track">
<div class="exp-bar-fill" style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
<div class="exp-bar-val">${_fmtShort(summe)}</div>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
<div class="exp-bar-chart">${balken}</div>
</div>`;
}
// ----------------------------------------------------------
// TAB: EINTRÄGE
// ----------------------------------------------------------
async function _renderEintraege(el) {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
if (!_entries.length) {
el.innerHTML = UI.emptyState({
icon: UI.icon('receipt'),
title: 'Noch keine Ausgaben',
text: 'Tippe auf + um deine erste Ausgabe einzutragen.',
});
return;
}
// Nach Monat gruppieren
const groups = {};
_entries.forEach(e => {
const m = e.datum.substring(0, 7);
if (!groups[m]) groups[m] = [];
groups[m].push(e);
});
const html = Object.entries(groups)
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([monat, items]) => {
const [y, m] = monat.split('-');
const titel = new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
const summe = items.reduce((s, e) => s + e.betrag, 0);
const rows = items.map(e => {
const k = _kat(e.kategorie);
const datum = new Date(e.datum + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const dogBadge = e.dog_name
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
: '';
const notiz = e.notiz
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
: '';
return `
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
${notiz}
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
aria-label="Eintrag löschen">
${UI.icon('trash')}
</button>
</div>
</div>`;
}).join('');
return `
<div class="exp-month-group">
<div class="exp-month-header">
<span class="exp-month-title">${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span>
</div>
${rows}
</div>`;
}).join('');
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
el.querySelectorAll('.exp-entry').forEach(row => {
row.addEventListener('click', (ev) => {
if (ev.target.closest('.exp-entry-del')) return;
const id = parseInt(row.dataset.id);
const entry = _entries.find(e => e.id === id);
if (entry) _showForm(entry);
});
});
// Löschen-Buttons
el.querySelectorAll('.exp-entry-del').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const id = parseInt(btn.dataset.del);
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
try {
await API.del(`/expenses/${id}`);
UI.toast.success('Ausgabe gelöscht.');
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
});
}
// ----------------------------------------------------------
// TAB: DAUERAUFTRÄGE
// ----------------------------------------------------------
const HAEUFIGKEIT_LABEL = {
monatlich: 'Monatlich',
quartalsweise: 'Quartalsweise',
jaehrlich: 'Jährlich',
};
async function _renderDauerauftraege(el) {
let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
const cards = recurring.map(r => {
const k = _kat(r.kategorie);
const naechste = r.naechste_faelligkeit
? new Date(r.naechste_faelligkeit + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—';
return `
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
</div>
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
<div class="exp-recurring-next">
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
</div>
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
</div>
</div>
</div>`;
}).join('');
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
${UI.icon('plus')} Dauerauftrag
</button>
</div>
${recurring.length
? `<div class="exp-list">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
<div style="height:80px"></div>`;
el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => {
_tab = 'dauerauftraege'; _renderTab();
}));
el.querySelectorAll('.exp-recurring-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const rid = parseInt(btn.dataset.rid);
const aktiv = btn.dataset.aktiv === '1';
await UI.asyncButton(btn, async () => {
await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv });
_renderTab();
});
});
});
el.querySelectorAll('.exp-recurring-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Dauerauftrag löschen?')) return;
await UI.asyncButton(btn, async () => {
await API.del(`/expenses/recurring/${btn.dataset.rid}`);
_renderTab();
});
});
});
}
function _showRecurringForm(r, onSave) {
const today = new Date().toISOString().slice(0, 10);
const katOptions = [
{ id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' },
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' },
{ id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' },
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
).join('');
const body = `
<form id="exp-recurring-form">
<div class="form-group">
<label class="form-label">Kategorie</label>
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Betrag ()</label>
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
value="${r?.betrag || ''}" placeholder="0,00" required>
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
<select class="form-control" name="haeufigkeit">
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Startdatum</label>
<input class="form-control" type="date" name="startdatum"
value="${r?.startdatum || today}" required>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
<select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="notiz"
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]');
const fd = UI.formData(e.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
await UI.asyncButton(btn, async () => {
if (r) {
await API.patch(`/expenses/recurring/${r.id}`, payload);
} else {
await API.post('/expenses/recurring', payload);
}
UI.modal.close();
onSave?.();
});
});
}
// ----------------------------------------------------------
// TAB: STATISTIK
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const s = _summary;
const gesamtJahr = s.gesamt_jahr || 1;
// Jahres-Aufteilung nach Kategorien (als Balken-Reihen)
const katBalken = KATEGORIEN
.filter(k => (s.jahr[k.id] || 0) > 0)
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
.map(k => {
const val = s.jahr[k.id] || 0;
const pct = Math.round((val / gesamtJahr) * 100);
return `
<div class="exp-stat-row">
<div class="exp-stat-label">
<span class="exp-stat-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
${k.label}
</div>
<div class="exp-stat-bar-wrap">
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
</div>
<div class="exp-stat-pct">${pct}%</div>
<div class="exp-stat-val">${_fmt(val)}</div>
</div>`;
}).join('');
// Monats-Balken mit gestapelten Top-2-Kategorien
const heute = new Date();
const jahrStr = heute.getFullYear().toString();
// Pro Monat: Summe je Kategorie berechnen
const monatKatMap = {}; // { monat: { katId: summe } }
_entries
.filter(e => e.datum.startsWith(jahrStr))
.forEach(e => {
const m = parseInt(e.datum.split('-')[1]);
if (!monatKatMap[m]) monatKatMap[m] = {};
monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag;
});
const monatTotalMap = {};
Object.entries(monatKatMap).forEach(([m, katObj]) => {
monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0);
});
const maxMonat = Math.max(...Object.values(monatTotalMap), 1);
const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const monatsBalken = MONATE.map((label, i) => {
const mi = i + 1;
const total = monatTotalMap[mi] || 0;
const pct = Math.round((total / maxMonat) * 100);
const isAktiv = mi === (heute.getMonth() + 1);
// Top-2-Kategorien für gestapelten Balken
let stackHtml = '';
if (total > 0 && monatKatMap[mi]) {
const sorted = Object.entries(monatKatMap[mi])
.sort((a, b) => b[1] - a[1])
.slice(0, 2);
// Gesamthöhe = pct%, verteile anteilig auf Top-2
let rest = pct;
const segments = sorted.map(([katId, val], idx) => {
const k = _kat(katId);
const segPct = idx < sorted.length - 1
? Math.round((val / total) * pct)
: rest;
rest -= segPct;
return `<div class="exp-stack-seg" style="height:${segPct}%;background:${k.color}" title="${k.label}: ${_fmt(val)}"></div>`;
});
stackHtml = segments.join('');
} else {
stackHtml = `<div class="exp-stack-seg" style="height:${pct}%;background:var(--c-border)"></div>`;
}
return `
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
<div class="exp-bar-track exp-bar-track--stack">
${stackHtml}
</div>
<div class="exp-bar-label">${label}</div>
</div>`;
}).join('');
// Donut-Übersicht (CSS-gradient)
const donutHtml = _donutHtml(s, gesamtJahr);
el.innerHTML = `
<div class="exp-hero-card exp-hero-card--sm">
<div class="exp-hero-label">Gesamt dieses Jahr</div>
<div class="exp-hero-betrag">${_fmt(s.gesamt_jahr)}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
</div>
${donutHtml}
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-pie')} Aufteilung nach Kategorie</div>
<div class="exp-stat-rows">
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
</div>
</div>
<div style="height:80px"></div>
`;
}
// Donut via CSS conic-gradient
function _donutHtml(s, gesamt) {
const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0);
if (!aktiveKat.length) return '';
// Stops für conic-gradient berechnen
let offset = 0;
const stops = [];
aktiveKat.forEach(k => {
const pct = (s.jahr[k.id] || 0) / gesamt * 100;
stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`);
offset += pct;
});
const gradient = `conic-gradient(${stops.join(', ')})`;
const legendeItems = aktiveKat
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
.map(k => {
const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100);
return `
<div class="exp-donut-legend-item">
<span class="exp-donut-dot" style="background:${k.color}"></span>
<span class="exp-donut-legend-label">${k.label}</span>
<span class="exp-donut-legend-pct">${pct}%</span>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-pie')} Kategorien-Verteilung</div>
<div class="exp-donut-wrap">
<div class="exp-donut" style="background:${gradient}">
<div class="exp-donut-hole"></div>
</div>
<div class="exp-donut-legend">${legendeItems}</div>
</div>
</div>`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const selKat = entry?.kategorie || 'sonstiges';
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
// Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => `
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
<span class="exp-kat-tile-label">${k.label}</span>
</label>`).join('');
const body = `
<form id="${formId}" autocomplete="off">
<div class="form-group">
<label class="form-label">Kategorie</label>
<div class="exp-kat-grid">${katKacheln}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Betrag</label>
<div class="exp-betrag-wrap">
<span class="exp-betrag-prefix"></span>
<input type="number" name="betrag" class="form-control exp-betrag-input"
value="${entry?.betrag || ''}" min="0.01" step="0.01"
placeholder="0,00" required>
</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label>
<input type="date" name="datum" class="form-control"
value="${entry?.datum || today}" required>
</div>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span class="form-label-hint">(optional)</span></label>
<select name="dog_id" class="form-control">
<option value=""> kein Hund </option>${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
<input type="text" name="notiz" class="form-control"
value="${_esc(entry?.notiz || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div>
${!isEdit ? `
<div class="exp-repeat-section">
<label class="exp-repeat-toggle">
<input type="checkbox" id="exp-wiederholen" name="wiederholen">
<span class="exp-repeat-toggle-box"></span>
<span>${UI.icon('arrows-clockwise')} Automatisch wiederholen</span>
</label>
<div id="exp-repeat-opts" style="display:none;margin-top:var(--space-3)">
<select name="haeufigkeit" class="form-control">
<option value="monatlich">Monatlich</option>
<option value="quartalsweise">Quartalsweise (alle 3 Monate)</option>
<option value="jaehrlich" selected>Jährlich</option>
</select>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0">
Der Betrag wird automatisch zum Fälligkeitstermin eingetragen.
</p>
</div>
</div>` : ''}
</form>`;
const footer = isEdit ? `
<button type="button" class="btn btn-ghost btn-sm" id="exp-delete-btn"
style="color:var(--c-danger);margin-right:auto">
${UI.icon('trash')}
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
` : `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
`;
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
// Kategorie-Kacheln interaktiv
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
tile.addEventListener('click', () => {
modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel'));
tile.classList.add('exp-kat-tile--sel');
});
});
// Wiederholen-Toggle
modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => {
modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none';
});
if (isEdit) {
modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => {
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
try {
await API.del(`/expenses/${entry.id}`);
UI.modal.close();
UI.toast.success('Ausgabe gelöscht.');
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
}
modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = UI.formData(ev.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
datum: fd.datum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
try {
if (isEdit) {
await API.patch(`/expenses/${entry.id}`, payload);
UI.toast.success('Ausgabe aktualisiert.');
} else {
await API.post('/expenses', payload);
// Auch als Dauerauftrag anlegen wenn gewünscht
if (fd.wiederholen) {
await API.post('/expenses/recurring', {
...payload,
haeufigkeit: fd.haeufigkeit || 'jaehrlich',
startdatum: fd.datum,
});
UI.toast.success('Ausgabe + Dauerauftrag gespeichert.');
} else {
UI.toast.success('Ausgabe gespeichert.');
}
}
UI.modal.close();
_invalidateCache();
await _renderTab();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
}
});
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _invalidateCache() {
_summary = null;
_entries = [];
_statsData = null;
}
function _fmt(val) {
return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
function _fmtShort(val) {
if (!val) return '0 €';
if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€';
return Math.round(val) + ' €';
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();

View file

@ -66,6 +66,7 @@ window.Page_forum = (() => {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
}
@ -98,15 +99,17 @@ window.Page_forum = (() => {
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button>
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
</div>
<!-- Rechte Spalte: Suche + Threads -->
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
<div class="forum-main-col">
<div id="forum-hdm-card"></div>
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
@ -127,6 +130,23 @@ window.Page_forum = (() => {
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
_tabsEl.addEventListener('mouseenter', e => {
const btn = e.target.closest('.by-tab');
const span = btn?.querySelector('.by-tab-text');
if (!span) return;
const style = getComputedStyle(btn);
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const overflow = span.scrollWidth - (btn.clientWidth - padH);
if (overflow <= 2) return;
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
span.classList.add('scrolling');
}, true);
_tabsEl.addEventListener('mouseleave', e => {
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
if (span) span.classList.remove('scrolling');
}, true);
// Tab-Klicks
_tabsEl.addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
@ -175,6 +195,177 @@ window.Page_forum = (() => {
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
}
// ----------------------------------------------------------
// Hund des Monats — Kachel + Modal
// ----------------------------------------------------------
async function _loadHdmCard() {
const card = document.getElementById('forum-hdm-card');
if (!card) return;
try {
const data = await API.get('/movies/hund-des-monats');
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
.format(new Date(+year, +month - 1, 1));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
: 'Sei der Erste!';
card.innerHTML = `
<div class="forum-hdm-tile" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body">
<div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
<div class="forum-hdm-tile-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div>
</div>
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
</div>`;
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
} catch {
// Kachel bleibt leer bei Fehler
}
}
async function _openHdmModal(data) {
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
const topList = data.top?.length
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
</div>`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
const voteHint = !_appState.user
? `<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um abstimmen zu können.
</p>
</div>`
: `<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
style="font-size:var(--text-sm)">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
</div>
</div>`;
const body = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteHint}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
if (!_appState.user) return;
// Kandidaten laden und rendern
let _kandidaten = [];
const _renderKandidaten = (list) => {
const grid = document.getElementById('hdm-kandidaten-grid');
if (!grid) return;
if (!list.length) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
return;
}
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
</button>
</div>`;
}).join('');
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
data.user_vote = dogId;
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
};
try {
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
} catch {
document.getElementById('hdm-kandidaten-grid').innerHTML =
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
return;
}
_renderKandidaten(_kandidaten);
document.getElementById('hdm-search')?.addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
_renderKandidaten(q
? _kandidaten.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.rasse || '').toLowerCase().includes(q))
: _kandidaten
);
});
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------

View file

@ -6,11 +6,13 @@
window.Page_health = (() => {
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _container = null;
let _appState = null;
let _data = {};
let _praxen = [];
let _activeTab = 'impfung';
let _favoritVet = null;
let _healthDocs = [];
const BASE_TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
@ -20,7 +22,6 @@ window.Page_health = (() => {
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
@ -150,6 +151,9 @@ window.Page_health = (() => {
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
</button>
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
@ -162,6 +166,8 @@ window.Page_health = (() => {
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
.addEventListener('click', _showKiTierarzt);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
@ -170,6 +176,7 @@ window.Page_health = (() => {
_renderTab();
_loadKiBerichte(dog.id);
_loadTerminvorschlaege(dog.id);
_loadMeinTierarzt();
}
// ----------------------------------------------------------
@ -342,6 +349,16 @@ window.Page_health = (() => {
} catch (err) {
_data['gewicht_chart'] = [];
}
try {
_favoritVet = await API.tieraerzte.myFavorite();
} catch (err) {
_favoritVet = null;
}
try {
_healthDocs = await API.healthDocs.list(dogId);
} catch (err) {
_healthDocs = [];
}
}
// ----------------------------------------------------------
@ -362,7 +379,6 @@ window.Page_health = (() => {
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'symptomcheck': _renderSymptomCheck(content); break;
}
_bindTabEvents(content);
@ -901,7 +917,8 @@ window.Page_health = (() => {
}).join('');
return `<div class="health-list">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
${_renderBefundeSection()}`;
}
// ----------------------------------------------------------
@ -957,6 +974,32 @@ window.Page_health = (() => {
// Praxis hinzufügen
content.querySelector('[data-action="add-praxis"]')
?.addEventListener('click', () => _showPraxForm(null));
// Favorit-Toggle für Praxen
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const vetId = parseInt(btn.dataset.praxisId);
await UI.asyncButton(btn, async () => {
const res = await API.tieraerzte.toggleFavorite(vetId);
if (res.is_favorite) {
_favoritVet = _praxen.find(p => p.id === vetId) || null;
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
} else {
_favoritVet = null;
UI.toast.success('Favorit entfernt.');
}
// is_favorite in _praxen aktualisieren
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
const elFav = _container.querySelector('#health-mein-tierarzt');
if (elFav) _renderMeinTierarztKachel(elFav);
_renderTab();
});
});
});
// Befunde & Dokumente
if (_activeTab === 'dokument') {
_bindBefundeEvents(content);
}
}
// ----------------------------------------------------------
@ -1597,7 +1640,9 @@ window.Page_health = (() => {
action: addBtn
});
const renderCard = p => `
const renderCard = p => {
const isFav = _favoritVet?.id === p.id || p.is_favorite;
return `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
@ -1626,17 +1671,40 @@ window.Page_health = (() => {
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
</button>
</div>
</div>
</div>
`;
`};
const favCard = _favoritVet ? `
<div style="margin-bottom:var(--space-4)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
${UI.icon('heart')} Mein Tierarzt
</div>
${renderCard(_favoritVet)}
</div>` : '';
const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id);
return `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
${addBtn}
</div>
${favCard}
<div class="health-list">
${aktive.map(renderCard).join('')}
${ohneGesetzt.map(renderCard).join('')}
${inaktive.length ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
border-top:1px solid var(--c-border)">
@ -2156,6 +2224,306 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// MEIN TIERARZT — Kachel
// ----------------------------------------------------------
async function _loadMeinTierarzt() {
const el = _container.querySelector('#health-mein-tierarzt');
if (!el) return;
_renderMeinTierarztKachel(el);
}
function _renderMeinTierarztKachel(el) {
if (!el) return;
const vet = _favoritVet;
const adresse = vet
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
: '';
el.innerHTML = `
<div style="margin:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
Mein Tierarzt
</div>
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.6rem;flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
${vet ? `
<div class="health-card-title">${_esc(vet.name)}</div>
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
${vet.telefon ? `
<div style="margin-top:var(--space-2)">
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
</a>
</div>` : ''}
` : `
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch kein Tierarzt als Favorit gespeichert.
</div>
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
id="health-suche-tierarzt-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
</button>
`}
</div>
${vet ? `
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
</button>
` : ''}
</div>
</div>
`;
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
App.navigate('map', { filter: 'tierarzt' });
});
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
await UI.asyncButton(btn, async () => {
await API.tieraerzte.toggleFavorite(_favoritVet.id);
_favoritVet = null;
const elAgain = _container.querySelector('#health-mein-tierarzt');
if (elAgain) _renderMeinTierarztKachel(elAgain);
UI.toast.success('Tierarzt-Favorit entfernt.');
});
});
}
// ----------------------------------------------------------
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
// ----------------------------------------------------------
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
function _renderBefundeSection() {
const dog = _appState.activeDog;
const docs = _healthDocs;
const DOC_ICONS = {
blutbild: 'drop',
roentgen: 'file-text',
rezept: 'note',
impfausweis:'certificate',
sonstiges: 'file-text',
};
const DOC_LABELS = {
blutbild: 'Blutbild',
roentgen: 'Röntgen',
rezept: 'Rezept',
impfausweis:'Impfausweis',
sonstiges: 'Sonstiges',
};
const uploadBtn = `
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
</button>`;
const items = docs.length
? docs.map(doc => {
const icon = DOC_ICONS[doc.typ] || 'file-text';
const label = DOC_LABELS[doc.typ] || doc.typ;
const isImg = !['pdf'].includes(doc.file_type);
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
return `
<div class="health-card" style="align-items:flex-start">
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
</div>
<div class="health-card-body" style="flex:1;min-width:0">
<div class="health-card-title">${_esc(doc.titel)}</div>
<div class="health-card-meta">
${_esc(label)}${datum ? ' · ' + datum : ''}
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
</div>
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
</div>`;
}).join('')
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
Noch keine Befunde hochgeladen.
</p>`;
return `
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.05em">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde &amp; Dokumente
</div>
${uploadBtn}
</div>
<div class="health-list" id="health-docs-list">${items}</div>
</div>
`;
}
function _bindBefundeEvents(content) {
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
_showBefundUploadModal();
});
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const docId = parseInt(btn.dataset.docId);
const ok = window.confirm('Befund wirklich löschen?');
if (!ok) return;
await UI.asyncButton(btn, async () => {
await API.healthDocs.delete(docId);
_healthDocs = _healthDocs.filter(d => d.id !== docId);
_renderTab();
UI.toast.success('Befund gelöscht.');
});
});
});
}
function _showBefundUploadModal() {
const aktivePraxen = _praxen.filter(p => p.aktiv);
const dog = _appState.activeDog;
UI.modal.open({
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
body: `
<form id="befund-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Art des Dokuments *</label>
<select class="form-control" name="typ" required>
<option value=""> bitte wählen </option>
<option value="blutbild">Blutbild</option>
<option value="roentgen">Röntgen</option>
<option value="rezept">Rezept</option>
<option value="impfausweis">Impfausweis</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel" required
placeholder="z.B. Blutbild März 2026">
</div>
<div class="form-group">
<label class="form-label">Untersuchungsdatum</label>
<input class="form-control" type="date" name="datum"
value="${new Date().toISOString().slice(0,10)}">
</div>
${aktivePraxen.length ? `
<div class="form-group">
<label class="form-label">Tierarzt / Praxis</label>
<select class="form-control" name="vet_id">
<option value=""> optional </option>
${aktivePraxen.map(p =>
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
).join('')}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Zusätzliche Infos (optional)"></textarea>
</div>
<div class="form-group">
<label class="form-label">Datei * (PDF, JPG, PNG, WebP max. 10 MB)</label>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
<input type="file" name="file" id="befund-file-input"
accept=".pdf,image/*"
required
style="position:absolute;opacity:0;width:1px;height:1px">
</label>
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
color:var(--c-text-secondary)"></div>
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
`,
});
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('befund-file-input')?.addEventListener('change', function () {
const preview = document.getElementById('befund-file-preview');
if (this.files?.length) {
const f = this.files[0];
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
} else {
preview.textContent = '';
}
});
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.querySelector('[form="befund-form"][type="submit"]');
const form = e.target;
const fd = UI.formData(form);
const fileInput = form.querySelector('[name="file"]');
const file = fileInput?.files?.[0];
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
if (file.size > 10 * 1024 * 1024) {
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
return;
}
await UI.asyncButton(btn, async () => {
const formData = new FormData();
formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ);
formData.append('titel', fd.titel);
formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', file);
try {
const doc = await API.healthDocs.upload(formData);
_healthDocs.unshift(doc);
UI.modal.close();
_renderTab();
UI.toast.success('Befund hochgeladen.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
});
}
// ----------------------------------------------------------
async function _showKiSummary() {
const btn = _container.querySelector('#health-ki-btn');
@ -2323,6 +2691,129 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-TIERARZTFRAGEN
// ----------------------------------------------------------
function _showKiTierarzt() {
const dog = _appState.activeDog;
const dogName = dog?.name || '';
const rasse = dog?.rasse || '';
const placeholder = dogName
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung
kein Ersatz für einen echten Tierarzt.
</p>
<div class="form-group">
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
placeholder="${_esc(placeholder)}"></textarea>
</div>
<div id="ki-tierarzt-result" style="display:none"></div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fff3cd;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#856404;
border:1px solid #ffc107">
<strong>&#9888;&#65039; Hinweis:</strong> Dies ist keine medizinische Diagnose.
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
document.getElementById('ki-tierarzt-submit-btn')
.addEventListener('click', async function () {
const btn = this;
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
const resultEl = document.getElementById('ki-tierarzt-result');
if (!symptom) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post('/ki/tierarzt', {
symptom,
dog_id: dog?.id || null,
dog_name: dogName || null,
rasse: rasse || null,
});
} catch (err) {
if (err.status === 429) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-warning);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div style="padding:var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);border:1px solid var(--c-danger);
font-size:var(--text-sm)">
KI momentan nicht verfügbar. Bitte später versuchen.
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
return;
}
resultEl.style.display = '';
return;
}
const antwortHtml = _esc(result.antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
.replace(/\n/g, '<br>');
const restHtml = result.limit - result.anfragen_heute > 0
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
</p>`
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Tageslimit erreicht. Morgen wieder verfügbar.
</p>`;
resultEl.innerHTML = `
<div style="margin-top:var(--space-4);padding:var(--space-4);
background:var(--c-surface);border-radius:var(--radius-md);
border:1px solid var(--c-border)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
Einschätzung
</div>
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
${restHtml}
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);
background:#fee2e2;border-radius:var(--radius-md);
font-size:var(--text-xs);color:#991b1b;
border:1px solid #fca5a5">
<strong>&#9888;&#65039; Dies ist keine medizinische Diagnose.</strong>
Bei ernsthaften Symptomen sofort zum Tierarzt.
</div>`;
resultEl.style.display = '';
// Submit-Button ausblenden wenn Limit erschöpft
if (result.anfragen_heute >= result.limit) {
btn.disabled = true;
btn.textContent = 'Limit erreicht';
}
});
});
}
return { init, refresh, openNew, onDogChange };
})();

View file

@ -0,0 +1,269 @@
/* ============================================================
BAN YARO Social-Media-Job Bewerbung
============================================================ */
window.Page_jobs = (() => {
let _container = null;
let _appState = null;
const _esc = s => UI.escape(s ?? '');
const _ph = (name, size = 22) =>
`<svg class="ph-icon" aria-hidden="true" style="width:${size}px;height:${size}px;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function _render() {
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
let existingApp = null;
let trialStatus = null;
if (_appState.user) {
try {
const r = await API.get('/jobs/my-application');
existingApp = r.application;
trialStatus = await API.get('/jobs/luna-trial-status');
} catch { /* ignorieren */ }
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Social-Media-Manager/in gesucht
</h1>
<p style="color:var(--c-text-secondary);margin:0">
Werde das Gesicht von Ban Yaro auf Instagram &amp; TikTok
</p>
</div>
<!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)">
${_infoRow(_ph('map-pin'), 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
${_infoRow(_ph('calendar-dots'), 'Umfang', '12 Posts pro Woche auf Instagram &amp; TikTok')}
${_infoRow(_ph('tag'), 'Vergütung', '50 € / Monat — wächst mit der Community')}
${_infoRow(_ph('robot'), 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
${_infoRow(_ph('star'), 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
</div>
</div>
</div>
<!-- Luna-Probezugang Teaser -->
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:24px;height:24px"><use href="/icons/phosphor.svg#robot"></use></svg>
Luna 14 Tage kostenlos testen
</div>
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
bevor du dich entscheidest.
</p>
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;vertical-align:middle"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
</div>
<!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Du hast einen Hund und liebst ihn sehr 🐕</li>
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
<li>Du schreibst gerne und authentisch auf Deutsch</li>
<li>Du hast Lust, eine junge App bekannt zu machen aus Überzeugung</li>
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
</ul>
</div>
</div>
<!-- Bewerbungsformular oder Status -->
${existingApp ? _renderStatus(existingApp) : _renderForm()}
</div>
`;
if (!existingApp) {
_bindForm();
}
}
function _infoRow(icon, label, text) {
return `
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="margin-top:1px">${icon}</div>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
</div>
</div>`;
}
function _renderStatus(app) {
const statusMap = {
pending: { icon: 'clock', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
reviewing: { icon: 'magnifying-glass', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
accepted: { icon: 'check-circle', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
rejected: { icon: 'x', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
};
const s = statusMap[app.status] || statusMap.pending;
return `
<div class="card" style="padding:var(--space-5);text-align:center">
<div style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:${s.color}"><use href="/icons/phosphor.svg#${s.icon}"></use></svg>
</div>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
</div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}</div>` : ''}
</div>`;
}
function _renderForm() {
const u = _appState.user;
return `
<div class="card">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
Jetzt bewerben
</h2>
<form id="jobs-form" novalidate>
<div class="form-group">
<label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name"
value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email"
value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
</div>
<div class="form-group" style="margin:0">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
</div>
</div>
<div class="form-group">
<label class="form-label">Instagram oder TikTok Handle *</label>
<div style="position:relative">
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted)">@</span>
<input class="form-control" type="text" name="social_handle"
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
</div>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Dein öffentliches Profil auf Instagram oder TikTok
</p>
</div>
<div class="form-group">
<label class="form-label">Warum du? *</label>
<textarea class="form-control" name="motivation" rows="5"
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
style="resize:vertical"></textarea>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Mindestens 80 Zeichen
</p>
</div>
<div class="form-group">
<label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
style="padding:var(--space-2)">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video.
</p>
</div>
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''}
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Bewerbung absenden + Luna freischalten
</button>
</form>
</div>
</div>`;
}
function _bindForm() {
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
e.preventDefault();
if (window.App) App.navigate('settings');
});
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = new FormData(e.target);
// Dateien aus file-input übernehmen
const fileInput = document.getElementById('jobs-files');
if (fileInput?.files?.length) {
fd.delete('files');
for (const f of fileInput.files) fd.append('files', f);
}
await UI.asyncButton(btn, async () => {
const resp = await fetch('/api/jobs/apply', {
method: 'POST',
body: fd,
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
credentials: 'include',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler beim Absenden.');
}
const result = await resp.json();
if (result.luna_trial) {
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
// User-State aktualisieren damit Luna sofort zugänglich ist
if (_appState.user && window.API) {
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
}
} else {
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
}
await _render();
});
});
}
return { init };
})();

View file

@ -13,6 +13,9 @@ window.Page_movies = (() => {
let _filme = [];
let _activeTab = 'filme';
let _filter = 'alle';
let _typ = 'alle'; // alle | film | serie | doku
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
let _search = '';
// ----------------------------------------------------------
// INIT
@ -39,7 +42,6 @@ window.Page_movies = (() => {
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
@ -64,26 +66,54 @@ window.Page_movies = (() => {
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
// TAB 1: FILME
// ----------------------------------------------------------
async function _loadFilme() {
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
}
async function _renderFilme(content) {
try {
_filme = await API.get('/movies/filme');
await _loadFilme();
} catch {
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return;
}
content.innerHTML = `
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg>+</button>
<div class="movies-controls">
<div class="movies-search-row">
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="movies-search" class="form-control movies-search-input"
placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
</div>
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
</div>
<div class="movies-filter-row" style="margin-top:var(--space-2)">
<button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'doku' ? ' movies-filter-btn--active' : ''}" data-typ="doku"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#camera"></use></svg> Dokus</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#list"></use></svg>
<select id="movies-sort" class="form-control" style="flex:1;font-size:var(--text-sm);padding:var(--space-2) var(--space-3)">
<option value="default" ${_sort==='default' ?'selected':''}>Empfohlen</option>
<option value="bewertung" ${_sort==='bewertung' ?'selected':''}>Community-Bewertung</option>
<option value="imdb" ${_sort==='imdb' ?'selected':''}>IMDb-Bewertung</option>
<option value="jahr_desc" ${_sort==='jahr_desc' ?'selected':''}>Neueste zuerst</option>
<option value="jahr_asc" ${_sort==='jahr_asc' ?'selected':''}>Älteste zuerst</option>
<option value="titel" ${_sort==='titel' ?'selected':''}>Titel AZ</option>
</select>
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
</div>
</div>
<div class="movie-grid" id="movie-grid"></div>
`;
@ -97,6 +127,31 @@ window.Page_movies = (() => {
});
});
content.querySelectorAll('.movies-type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
_typ = btn.dataset.typ;
content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
});
content.querySelector('#movies-search')?.addEventListener('input', e => {
_search = e.target.value.trim().toLowerCase();
_renderMovieGrid(content.querySelector('#movie-grid'));
});
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
_sort = e.target.value;
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
_renderMovieGrid(content.querySelector('#movie-grid'));
}
@ -106,7 +161,18 @@ window.Page_movies = (() => {
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
if (_search) {
list = list.filter(f =>
(f.titel || '').toLowerCase().includes(_search) ||
(f.hund_rasse || '').toLowerCase().includes(_search) ||
(f.genre || '').toLowerCase().includes(_search) ||
(f.beschreibung || '').toLowerCase().includes(_search)
);
}
const countEl = document.getElementById('movies-count');
if (countEl) countEl.textContent = `${list.length} Einträge`;
if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
@ -130,18 +196,25 @@ window.Page_movies = (() => {
function _movieCard(film) {
const stirbt = film.stirbt_der_hund;
const tag = stirbt
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> ACHTUNG: Der Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Der Hund überlebt</div>`;
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Hund überlebt</div>`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
const _ico = name => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:middle"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : '';
const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</div>
<div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
</div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div>
</div>
</div>

View file

@ -0,0 +1,708 @@
/* ============================================================
BAN YARO Playdate-Matching
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
============================================================ */
window.Page_playdate = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
let _userPos = null;
let _radius = 10;
let _dogs = [];
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T'));
return d.toLocaleDateString('de-DE');
}
function _dogAvatar(foto_url, name, size = 48) {
const initials = _esc((name || '?').charAt(0).toUpperCase());
if (foto_url) {
return `<img src="${_esc(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
}
return `<div style="width:${size}px;height:${size}px;border-radius:50%;
background:var(--c-primary-subtle);display:flex;align-items:center;
justify-content:center;font-size:${Math.round(size * 0.45)}px;
font-weight:700;color:var(--c-primary);">${initials}</div>`;
}
function _statusBadge(status) {
const map = {
pending: ['warning', 'Ausstehend'],
accepted: ['success', 'Angenommen'],
declined: ['danger', 'Abgelehnt'],
};
const [type, label] = map[status] || ['default', status];
const colors = {
warning: 'var(--c-warning, #f59e0b)',
success: 'var(--c-success, #10b981)',
danger: 'var(--c-danger, #ef4444)',
default: 'var(--c-text-muted)',
};
return `<span style="font-size:var(--text-xs);font-weight:600;
color:${colors[type]};padding:2px 8px;border-radius:999px;
background:${colors[type]}18">${label}</span>`;
}
// ------------------------------------------------------------------
// INIT
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
_render();
_switchTab(_activeTab);
}
function refresh() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
_switchTab(_activeTab);
}
function onDogChange() {
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
if (_activeTab === 'listings') _loadListings();
}
// ------------------------------------------------------------------
// RENDER — Grundstruktur mit Tabs
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="playdate-layout">
<!-- Tabs -->
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
<button class="by-tab" data-tab="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests">
Anfragen
<span id="playdate-req-badge" style="display:none;margin-left:4px;
background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 6px;font-size:var(--text-xs);font-weight:700">0</span>
</button>
</div>
<!-- Tab-Inhalt -->
<div id="playdate-content"></div>
</div>
`;
document.getElementById('playdate-tabs').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
}
function _switchTab(tab) {
_activeTab = tab;
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
const content = document.getElementById('playdate-content');
if (!content) return;
if (tab === 'nearby') _renderNearby(content);
if (tab === 'listings') _renderListings(content);
if (tab === 'requests') _renderRequests(content);
}
// ------------------------------------------------------------------
// TAB: IN DER NÄHE
// ------------------------------------------------------------------
async function _renderNearby(el) {
el.innerHTML = `
<div>
<!-- Toolbar: Radius-Auswahl + Standort-Button -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('map-pin')}
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span>
</div>
<select id="nearby-radius" class="form-select" style="width:auto;font-size:var(--text-sm)">
<option value="5" ${_radius===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${_radius===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${_radius===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${_radius===50 ? 'selected' : ''}>50 km</option>
</select>
<button class="btn btn-ghost btn-sm" id="nearby-locate-btn">
${UI.icon('crosshair')} Standort aktualisieren
</button>
</div>
<!-- Info-Hinweis -->
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-4);padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md)">
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
Dein genauer Standort bleibt privat es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
</div>
<!-- Ergebnisse -->
<div id="nearby-results">
<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">
Standort wird ermittelt
</p>
</div>
</div>
`;
document.getElementById('nearby-radius').addEventListener('change', e => {
_radius = parseInt(e.target.value, 10);
_loadNearby();
});
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
const btn = document.getElementById('nearby-locate-btn');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort aktualisiert';
await _loadNearby();
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(btn, false);
}
});
if (!_userPos) {
try {
_userPos = await API.getLocation();
const label = document.getElementById('nearby-location-label');
if (label) label.textContent = 'Standort bekannt';
} catch {
document.getElementById('nearby-results').innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
<p style="margin:var(--space-3) 0 var(--space-4)">
Standort konnte nicht automatisch ermittelt werden.<br>
Klicke auf "Standort aktualisieren".
</p>
</div>
`;
return;
}
}
await _loadNearby();
}
async function _loadNearby() {
if (!_userPos) return;
const resultsEl = document.getElementById('nearby-results');
if (!resultsEl) return;
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Suche…</p>`;
try {
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
if (!data || data.length === 0) {
resultsEl.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Niemand in der Nähe',
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
});
return;
}
resultsEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
${data.map(d => _nearbyCard(d)).join('')}
</div>
`;
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const toDogId = parseInt(btn.dataset.dogId, 10);
const dogName = btn.dataset.dogName;
_showRequestModal(toDogId, dogName);
});
});
} catch (err) {
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _nearbyCard(d) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(d.foto_url, d.dog_name, 56)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
color:var(--c-text)">${_esc(d.dog_name)}</div>
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
</span>
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
</div>
${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
${_esc(d.beschreibung)}
</p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
data-dog-id="${d.dog_id}"
data-dog-name="${_esc(d.dog_name)}">
${UI.icon('paw-print')} Spielkamerad anfragen
</button>
</div>
`;
}
function _showRequestModal(toDogId, dogName) {
const formId = 'playdate-req-form';
UI.modal.open({
title: `Anfrage an ${dogName}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Nachricht (optional)</label>
<textarea id="req-nachricht" class="form-control" rows="3" maxlength="500"
placeholder="Hallo! Unsere Hunde könnten super zusammenpassen…"></textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="req-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="req-send-btn" form="${formId}">
${UI.icon('paper-plane-tilt')} Anfrage senden
</button>
`,
});
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('req-send-btn').addEventListener('click', async () => {
const btn = document.getElementById('req-send-btn');
const nachricht = document.getElementById('req-nachricht').value.trim();
await UI.asyncButton(btn, async () => {
const result = await API.post('/playdate/request', {
to_dog_id: toDogId,
nachricht: nachricht || null,
});
UI.modal.close();
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
// Zum Chat navigieren
if (result.conversation_id) {
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
}
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: MEINE INSERATE
// ------------------------------------------------------------------
async function _renderListings(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
await _loadListings(el);
}
async function _loadListings(el) {
const target = el || document.getElementById('playdate-content');
if (!target) return;
if (_dogs.length === 0) {
target.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch kein Hund',
text: 'Lege zuerst einen Hund in deinem Profil an.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
});
return;
}
// Listings für alle eigenen Hunde laden
const listings = {};
await Promise.all(_dogs.map(async dog => {
try {
const data = await API.get(`/playdate/my-listing/${dog.id}`);
listings[dog.id] = data;
} catch {
listings[dog.id] = null;
}
}));
target.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
</div>
`;
// Event-Delegation für alle Buttons
target.addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const dogId = parseInt(btn.dataset.dogId, 10);
const dog = _dogs.find(d => d.id === dogId);
if (action === 'edit') {
_showListingModal(dog, listings[dogId], async () => {
await _loadListings();
});
}
if (action === 'deactivate') {
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
try {
await API.del(`/playdate/listing/${dogId}`);
UI.toast.success('Inserat deaktiviert.');
await _loadListings();
} catch (err) {
UI.toast.error(err.message);
}
}
});
}
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
</div>
<span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px;
background:${isAktiv ? 'var(--c-success-subtle,#d1fae5)' : 'var(--c-surface-2)'};
color:${isAktiv ? 'var(--c-success,#10b981)' : 'var(--c-text-muted)'}">
${isAktiv ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')}
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km
</div>
${listing.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Noch kein Inserat trage dich ein, damit andere dich finden können.
</p>
`}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-primary btn-sm"
data-action="edit" data-dog-id="${dog.id}">
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
</button>
${isAktiv ? `
<button class="btn btn-ghost btn-sm"
data-action="deactivate" data-dog-id="${dog.id}">
${UI.icon('x')} Deaktivieren
</button>` : ''}
</div>
</div>
`;
}
function _showListingModal(dog, existing, onSaved) {
const formId = 'listing-form';
UI.modal.open({
title: `Inserat für ${dog.name}`,
body: `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Ort / Standort</label>
<div style="display:flex;gap:var(--space-2)">
<input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München"
value="${_esc(existing?.ort_name || '')}">
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln">
${UI.icon('crosshair')}
</button>
</div>
<input type="hidden" id="listing-lat" value="${existing?.lat || ''}">
<input type="hidden" id="listing-lon" value="${existing?.lon || ''}">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln.
Nur der Ortsname wird für andere sichtbar nicht dein genauer Standort.
</div>
</div>
<div class="form-group">
<label class="form-label">Suchradius</label>
<select id="listing-radius" class="form-control">
<option value="5" ${(existing?.radius_km||10)===5 ? 'selected' : ''}>5 km</option>
<option value="10" ${(existing?.radius_km||10)===10 ? 'selected' : ''}>10 km</option>
<option value="25" ${(existing?.radius_km||10)===25 ? 'selected' : ''}>25 km</option>
<option value="50" ${(existing?.radius_km||10)===50 ? 'selected' : ''}>50 km</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Beschreibung (optional)</label>
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="listing-cancel-btn">Abbrechen</button>
<button class="btn btn-primary" id="listing-save-btn">
${UI.icon('floppy-disk')} Speichern
</button>
`,
});
// GPS-Button
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
const gpsBtn = document.getElementById('listing-gps-btn');
UI.setLoading(gpsBtn, true);
try {
const pos = await API.getLocation();
document.getElementById('listing-lat').value = pos.lat;
document.getElementById('listing-lon').value = pos.lon;
// Reverse-Geocoding für Ortsname
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
{ cache: 'no-store' }
);
const geoData = await rev.json();
const a = geoData.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) document.getElementById('listing-ort').value = ort;
} catch {}
UI.toast.success('Standort ermittelt.');
} catch {
UI.toast.error('Standort konnte nicht ermittelt werden.');
} finally {
UI.setLoading(gpsBtn, false);
}
});
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
document.getElementById('listing-save-btn').addEventListener('click', async () => {
const btn = document.getElementById('listing-save-btn');
const lat = parseFloat(document.getElementById('listing-lat').value);
const lon = parseFloat(document.getElementById('listing-lon').value);
const ort = document.getElementById('listing-ort').value.trim();
const rad = parseInt(document.getElementById('listing-radius').value, 10);
const desc = document.getElementById('listing-beschreibung').value.trim();
if (!lat || !lon) {
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
return;
}
await UI.asyncButton(btn, async () => {
await API.put('/playdate/listing', {
dog_id: dog.id,
lat,
lon,
ort_name: ort || null,
radius_km: rad,
beschreibung: desc || null,
});
UI.modal.close();
UI.toast.success('Inserat gespeichert!');
onSaved?.();
}, { errorMsg: null });
});
}
// ------------------------------------------------------------------
// TAB: ANFRAGEN
// ------------------------------------------------------------------
async function _renderRequests(el) {
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
try {
const data = await API.get('/playdate/requests');
const incoming = data.incoming || [];
const outgoing = data.outgoing || [];
// Badge aktualisieren
const pendingCount = incoming.filter(r => r.status === 'pending').length;
const badge = document.getElementById('playdate-req-badge');
if (badge) {
badge.textContent = pendingCount;
badge.style.display = pendingCount > 0 ? '' : 'none';
}
if (incoming.length === 0 && outgoing.length === 0) {
el.innerHTML = UI.emptyState({
icon: UI.icon('paw-print'),
title: 'Noch keine Anfragen',
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
});
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
${incoming.length > 0 ? `
<div>
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${incoming.map(r => _incomingCard(r)).join('')}
</div>
</div>` : ''}
${outgoing.length > 0 ? `
<div>
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${outgoing.map(r => _outgoingCard(r)).join('')}
</div>
</div>` : ''}
</div>
`;
// Button-Events (Accept/Decline)
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const reqId = parseInt(btn.dataset.reqId, 10);
const status = btn.dataset.status;
await UI.asyncButton(btn, async () => {
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
if (status === 'accepted' && result.conversation_id) {
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
setTimeout(() => {
App.navigate('chat', true, { conversation_id: result.conversation_id });
}, 800);
} else {
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
}
await _renderRequests(el);
}, { errorMsg: null });
});
});
// Chat-Buttons
el.querySelectorAll('.req-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
App.navigate('chat', true);
});
});
} catch (err) {
el.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
}
}
function _incomingCard(r) {
const isPending = r.status === 'pending';
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5">
"${_esc(r.nachricht)}"
</div>` : ''}
${isPending ? `
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen
</button>
<button class="btn btn-ghost btn-sm req-decline-btn"
data-req-id="${r.id}" data-status="declined">
${UI.icon('x')} Ablehnen
</button>
</div>` : `
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Zum Chat
</button>` : ''}
`}
</div>
`;
}
function _outgoingCard(r) {
return `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
"${_esc(r.nachricht)}"
</p>` : ''}
${r.status === 'accepted' ? `
<button class="btn btn-ghost btn-sm req-chat-btn">
${UI.icon('chat-circle-dots')} Chat öffnen
</button>` : ''}
</div>
`;
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,188 @@
/* ============================================================
BAN YARO Tierfutter-Rückrufe
Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
============================================================ */
window.Page_recalls = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _recalls = [];
let _query = '';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_query = '';
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_recalls = [];
_query = '';
await _render();
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<!-- Warnbanner -->
<div class="recalls-warning-banner">
<svg class="ph-icon recalls-warning-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<p class="recalls-warning-text">
<strong>Hinweis:</strong> Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
</p>
</div>
<!-- Suchfeld -->
<div style="position:relative;margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);pointer-events:none">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input type="search" id="recalls-search" placeholder="Produkt, Gefahr oder Herkunft suchen…"
value="${UI.escape(_query)}"
style="width:100%;padding:var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3)*2 + 1.2rem);
border:1px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);background:var(--c-surface);color:var(--c-text);
box-sizing:border-box">
</div>
<!-- Ergebnis-Liste -->
<div id="recalls-list">${UI.skeleton(4)}</div>
`;
// Suchfeld-Handler
_container.querySelector('#recalls-search').addEventListener('input', (e) => {
_query = e.target.value.trim();
_renderList();
});
await _loadRecalls();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadRecalls() {
try {
const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
_recalls = await API.get(url);
} catch {
_container.querySelector('#recalls-list').innerHTML = UI.emptyState({
icon: 'warning-circle',
title: 'Rückrufe konnten nicht geladen werden',
text: 'Bitte versuche es später erneut.',
});
return;
}
_renderList();
}
// ----------------------------------------------------------
// LISTE RENDERN
// ----------------------------------------------------------
function _renderList() {
const listEl = _container.querySelector('#recalls-list');
if (!listEl) return;
const filtered = _query
? _recalls.filter(r => {
const q = _query.toLowerCase();
return (r.titel || '').toLowerCase().includes(q)
|| (r.produkt || '').toLowerCase().includes(q)
|| (r.gefahr || '').toLowerCase().includes(q)
|| (r.herkunft || '').toLowerCase().includes(q);
})
: _recalls;
if (!filtered.length) {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
listEl.innerHTML = UI.emptyState({
icon: UI.icon('check-circle'),
title: 'Aktuell keine Rückrufe',
text: `Letzte Prüfung: ${today}`,
});
return;
}
listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
}
// ----------------------------------------------------------
// EINZELNE KARTE
// ----------------------------------------------------------
function _cardHtml(r) {
const datum = r.datum
? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const meta = [
r.herkunft ? `<span>${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}</span>` : '',
datum ? `<span>${UI.icon('calendar-blank')} ${datum}</span>` : '',
r.quelle ? `<span style="text-transform:uppercase;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(r.quelle)}</span>` : '',
].filter(Boolean).join('<span style="color:var(--c-border)"> · </span>');
const linkHtml = r.url
? `<a href="${UI.escape(r.url)}" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);
color:var(--c-primary);text-decoration:none;margin-top:var(--space-1)">
${UI.icon('arrow-square-out')} Details auf RASFF
</a>`
: '';
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-left:4px solid #dc2626;border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)">
<!-- Titel -->
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
<use href="/icons/phosphor.svg#warning-octagon"></use>
</svg>
<strong style="font-size:var(--text-base);color:var(--c-text);line-height:1.4">
${UI.escape(r.produkt || r.titel)}
</strong>
</div>
<!-- Gefahr -->
${r.gefahr ? `
<p style="margin:0 0 var(--space-2) 0;font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${UI.escape(r.gefahr)}
</p>` : ''}
<!-- Meta-Zeile -->
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-muted);
padding-left:calc(var(--space-2) + 1.2rem)">
${meta}
</div>
<!-- Link -->
${linkHtml ? `<div style="padding-left:calc(var(--space-2) + 1.2rem)">${linkHtml}</div>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -263,6 +263,12 @@ window.Page_settings = (() => {
<span>Kalender abonnieren</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-worlds-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
@ -653,6 +659,11 @@ window.Page_settings = (() => {
}
});
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
@ -1238,6 +1249,49 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderVerifyPending(email) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
<div style="margin-bottom:var(--space-5)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
<p style="margin:0 0 var(--space-2)">
Wir haben einen Bestätigungslink an<br>
<strong>${email}</strong><br>
gesendet.
</p>
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
</p>
</div>
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
style="margin-bottom:var(--space-3)">
Link erneut senden
</button>
<button id="verify-back-btn" class="btn btn-ghost w-full"
style="color:var(--c-text-muted);font-size:var(--text-sm)">
Anderes Konto / Anmelden
</button>
</div>
`;
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Gesendet …';
try {
await API.post('/auth/resend-verification', { email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
} catch {
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
}
});
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
}
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
@ -1467,7 +1521,16 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const result = await API.auth.login(fd.email, fd.password);
let result;
try {
result = await API.auth.login(fd.email, fd.password);
} catch (err) {
if (err.message === 'EMAIL_NOT_VERIFIED') {
_renderVerifyPending(fd.email);
return;
}
throw err;
}
localStorage.setItem('by_token', result.token);
// User-Daten laden
@ -1583,22 +1646,12 @@ window.Page_settings = (() => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
_appState.dogs = [];
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
const greeting = _appState.user.is_founder_pending
? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
: _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding();
if (result.pending_verification) {
_renderVerifyPending(fd.email);
return;
}
});
});
}

View file

@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => {
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
if (!streak) return;
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
} else if (streak.current_streak > 1) {
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
}
}).catch(() => {});
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {

View file

@ -463,6 +463,8 @@ window.Page_welcome = (() => {
`).join('')}
</div>
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
<div class="wc-grid">
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}
}
// ----------------------------------------------------------
// STREAK-WIDGET
// ----------------------------------------------------------
async function _loadStreakWidget(dogId) {
const slot = _container.querySelector('#wc-streak-widget');
if (!slot) return;
let streak;
try {
streak = await API.get(`/streak/${dogId}`);
} catch { return; }
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
slot.innerHTML = _streakWidgetHTML(streak);
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
const modalEl = UI.modal.open({
title: '🔥 Trainings-Bestenliste',
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
});
let board;
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
const bodyEl = modalEl?.querySelector('.modal-body');
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
});
}
function _streakWidgetHTML(s) {
const cur = s.current_streak || 0;
const best = s.longest_streak || 0;
return `
<div class="wc-streak-card">
<div class="wc-streak-flame-wrap">
<span class="wc-streak-flame">🔥</span>
<span class="wc-streak-number">${cur}</span>
</div>
<div class="wc-streak-info">
<div class="wc-streak-label">Tage in Folge trainiert</div>
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
</div>
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
</div>`;
}
function _leaderboardHTML(rows) {
if (!rows || !rows.length) {
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
}
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rows.map((r, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
${r.foto_url
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<span style="font-size:1.1rem">🔥</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
</div>
</div>
`).join('')}
</div>`;
}
function _updateHeroFromDash(dash, dog) {
const heroBox = _container.querySelector('#wc-hero-box');
if (!heroBox) return;

View file

@ -0,0 +1,581 @@
/* ============================================================
BAN YARO Wetter (7-Tage-Wettervorhersage)
Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS.
============================================================ */
window.Page_wetter = (() => {
// ----------------------------------------------------------
// KONSTANTEN
// ----------------------------------------------------------
// WMO-Code → Phosphor-Icon-Name (aus Sprite)
const WMO_ICON = {
0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud',
45:'cloud-fog', 48:'cloud-fog',
51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain',
61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain',
71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake',
80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain',
85:'cloud-snow', 86:'cloud-snow',
95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning',
};
// Farben passend zum Wetter (für Icon-Tinting)
const WMO_COLOR = {
0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B',
45:'#94A3B8', 48:'#94A3B8',
51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB',
61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8',
71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD',
80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB',
85:'#7DD3FC', 86:'#38BDF8',
95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6',
};
function _wmoIcon(code, size = '2rem', extraStyle = '') {
const name = WMO_ICON[code] || 'cloud';
const color = WMO_COLOR[code] || 'var(--c-text-secondary)';
return `<svg class="ph-icon" aria-hidden="true"
style="width:${size};height:${size};color:${color};flex-shrink:0;${extraStyle}">
<use href="/icons/phosphor.svg#${name}"></use>
</svg>`;
}
const WMO_DESC = {
0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt',
45:'Nebel', 48:'Gefrierender Nebel',
51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen',
61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen',
71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner',
80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer',
85:'Leichte Schneeschauer', 86:'Starke Schneeschauer',
95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel'
};
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _data = null;
let _selDay = 0;
let _loading = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div id="wttr-body">
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
<p style="color:var(--c-text-secondary)">Standort wird ermittelt</p>
</div>
</div>
`;
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
await _loadData(pos.lat, pos.lon);
} catch {
_showLocationError();
}
}
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">📍</div>
<h3 style="margin-bottom:var(--space-2)">Standort nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:300px;margin-inline:auto">
Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden.
</p>
<button class="btn btn-primary" id="wttr-btn-retry">
${UI.icon('map-pin')} Nochmal versuchen
</button>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _loadData(lat, lon) {
if (_loading) return;
_loading = true;
try {
_data = await API.weather.forecast(lat, lon);
_selDay = 0;
_renderWeather();
} catch {
const body = _container.querySelector('#wttr-body');
if (body) body.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)"></div>
<h3 style="margin-bottom:var(--space-2)">Wetter nicht verfügbar</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5)">
Die Wetterdaten konnten nicht geladen werden.
</p>
<button class="btn btn-primary" id="wttr-btn-reload">
${UI.icon('arrow-clockwise')} Erneut laden
</button>
</div>
`;
body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => {
refresh();
});
} finally {
_loading = false;
}
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _renderWeather() {
const body = _container.querySelector('#wttr-body');
if (!body || !_data) return;
const days = _data.days || [];
if (!days.length) return;
body.innerHTML = `
<!-- 7-Tage-Strip -->
<div id="wttr-strip-wrap"
style="overflow-x:auto;-webkit-overflow-scrolling:touch;
margin-bottom:var(--space-4);
scrollbar-width:none">
<div id="wttr-strip"
style="display:flex;gap:var(--space-2);padding-bottom:4px;min-width:max-content">
${days.map((d, i) => _dayCard(d, i)).join('')}
</div>
</div>
<!-- Detail-Card -->
<div id="wttr-detail" class="section-card"
style="margin-bottom:var(--space-4)">
</div>
<!-- Hunde-Wetter -->
<div id="wttr-dog" class="section-card">
</div>
`;
// Strip-Klick-Events
body.querySelectorAll('[data-wttr-day]').forEach(card => {
card.addEventListener('click', () => {
_selDay = parseInt(card.dataset.wttrDay);
_updateStrip();
_renderDetail();
_renderDog();
});
});
_renderDetail();
_renderDog();
}
// ----------------------------------------------------------
// STRIP AKTUALISIEREN (aktiver Tag)
// ----------------------------------------------------------
function _updateStrip() {
const body = _container.querySelector('#wttr-body');
if (!body) return;
const days = _data?.days || [];
body.querySelectorAll('[data-wttr-day]').forEach((card, i) => {
const active = i === _selDay;
card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
card.style.color = active ? '#fff' : 'var(--c-text)';
card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
card.style.transform = active ? 'translateY(-2px)' : '';
card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)';
// Temperatur-Farbe im aktiven Zustand
const tempEl = card.querySelector('.wttr-temp');
if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const precipEl = card.querySelector('.wttr-precip');
if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
});
}
// ----------------------------------------------------------
// TAG-KARTE (Strip)
// ----------------------------------------------------------
function _dayCard(d, i) {
const active = i === _selDay;
const dateObj = new Date(d.date);
const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()];
const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)';
const col = active ? '#fff' : 'var(--c-text)';
const shadow = active
? '0 4px 12px rgba(196,132,58,0.3)'
: '0 1px 3px rgba(0,0,0,0.07)';
const border = active ? 'var(--c-primary)' : 'var(--c-border)';
const transform = active ? 'translateY(-2px)' : '';
const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)';
const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)';
return `
<div data-wttr-day="${i}"
style="display:flex;flex-direction:column;align-items:center;
min-width:72px;padding:var(--space-3) var(--space-2);
border-radius:var(--radius);border:1.5px solid ${border};
background:${bg};color:${col};cursor:pointer;
box-shadow:${shadow};transform:${transform};
transition:all .15s;user-select:none">
<span style="font-size:var(--text-xs);font-weight:600;
margin-bottom:var(--space-1)">${_esc(dayName)}</span>
<div style="margin-bottom:var(--space-1)">${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}</div>
<span class="wttr-temp"
style="font-size:var(--text-xs);color:${textSec};white-space:nowrap">
${Math.round(d.temp_max)}°/<span style="opacity:.75">${Math.round(d.temp_min)}°</span>
</span>
<span class="wttr-precip"
style="font-size:10px;color:${textMut};margin-top:2px">
<svg class="ph-icon" style="width:10px;height:10px;vertical-align:-1px;color:#60A5FA"><use href="/icons/phosphor.svg#drop"></use></svg>${d.precip_prob ?? 0}%
</span>
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-CARD
// ----------------------------------------------------------
function _renderDetail() {
const el = _container.querySelector('#wttr-detail');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const desc = WMO_DESC[d.weathercode] || '';
const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0);
const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100);
const bft = _beaufort(d.wind_kmh ?? 0);
const windDir = d.wind_dir_deg ?? 0;
const compass = d.wind_dir ?? _compass(windDir);
// Sunrise/Sunset Balken
const now = new Date();
const sunriseStr = d.sunrise || '';
const sunsetStr = d.sunset || '';
let sunPct = 0;
if (sunriseStr && sunsetStr) {
const [rH, rM] = sunriseStr.split(':').map(Number);
const [sH, sM] = sunsetStr.split(':').map(Number);
const riseMin = rH * 60 + rM;
const setMin = sH * 60 + sM;
const curMin = now.getHours() * 60 + now.getMinutes();
sunPct = _selDay === 0
? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100))
: 0;
}
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
${_wmoIcon(d.weathercode, '3.5rem')}
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(desc)}</div>
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary);line-height:1.1">
${Math.round(d.temp_max)}°
<span style="font-size:var(--text-base);font-weight:400;color:var(--c-text-secondary)">
/ ${Math.round(d.temp_min)}°
</span>
</div>
${d.feels_max != null ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}°
</div>` : ''}
</div>
</div>
<!-- Sonnenaufgang / -untergang -->
${sunriseStr && sunsetStr ? `
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-1)">
<span style="display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:14px;height:14px;color:#F97316"><use href="/icons/phosphor.svg#sun-horizon"></use></svg>
${_esc(sunriseStr)}
</span>
<span style="display:flex;align-items:center;gap:4px">
${_esc(sunsetStr)}
<svg class="ph-icon" style="width:14px;height:14px;color:#7C3AED"><use href="/icons/phosphor.svg#moon-stars"></use></svg>
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${sunPct}%;
background:linear-gradient(90deg,#f97316,#facc15);
border-radius:999px;transition:width .4s"></div>
</div>
</div>` : ''}
<!-- Wind -->
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:var(--c-bg-card);border:1px solid var(--c-border);
margin-bottom:var(--space-3)">
<span style="font-size:1.4rem;transform:rotate(${windDir}deg);display:inline-block;line-height:1">
${UI.icon('arrow-up')}
</span>
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:600">
${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(bft)}</div>
</div>
${d.precip_sum != null ? `
<div style="text-align:right">
<div style="font-size:var(--text-sm);font-weight:600">
${d.precip_sum} mm
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Niederschlag</div>
</div>` : ''}
</div>
<!-- UV-Index -->
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:4px">
<span style="color:var(--c-text-secondary)">UV-Index</span>
<span style="font-weight:600;color:${uvColor}">
${d.uv_index ?? 0} ${_esc(uvLabel)}
</span>
</div>
<div style="height:6px;border-radius:999px;background:var(--c-border);overflow:hidden">
<div style="height:100%;width:${uvPct}%;background:${uvColor};
border-radius:999px;transition:width .4s"></div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// HUNDE-WETTER
// ----------------------------------------------------------
function _renderDog() {
const el = _container.querySelector('#wttr-dog');
if (!el || !_data) return;
const d = (_data.days || [])[_selDay];
if (!d) return;
const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' };
let html = `<h3 style="font-size:var(--text-base);font-weight:700;
margin-bottom:var(--space-4)">
<svg class="ph-icon" style="width:1.1em;height:1.1em;vertical-align:-2px;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Hunde-Wetter
</h3>`;
// Asphalt-Temperatur
if (d.asphalt_temp != null) {
const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp);
html += `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${aspColor}1a;border:1px solid ${aspColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:${aspColor}">
Asphalt ~${Math.round(d.asphalt_temp)}°C ${_esc(aspText)}
</div>
${aspAdvice ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(aspAdvice)}
</div>` : ''}
</div>
</div>
`;
}
// Pfoten-Kälteschutz
if (d.paw_cold) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#3b82f61a;border:1px solid #3b82f655;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#38BDF8"><use href="/icons/phosphor.svg#snowflake"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Kälteschutz für Pfoten:</strong>
Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen.
</div>
</div>
`;
}
// Gewitter
if (d.thunderstorm) {
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:#f59e0b1a;border:1px solid #f59e0b55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#7C3AED"><use href="/icons/phosphor.svg#cloud-lightning"></use></svg>
<div style="font-size:var(--text-sm)">
<strong>Gewitter erwartet:</strong>
Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen.
</div>
</div>
`;
}
// Pollenflug
const pollen = d.pollen;
if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) {
const pollenEntries = Object.entries(pollen)
.filter(([, v]) => v != null && v.level > 0);
if (pollenEntries.length) {
html += `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:1em;height:1em;vertical-align:-1px;color:#16A34A"><use href="/icons/phosphor.svg#leaf"></use></svg>
Pollenflug
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${pollenEntries.map(([key, lvlObj]) => {
const col = _pollenColor(lvlObj?.level ?? 0);
const name = _POLLEN_NAMES[key] || key;
const lbl = lvlObj?.label || '';
return `<span style="display:inline-flex;align-items:center;gap:4px;
font-size:var(--text-xs);border-radius:999px;
padding:3px 10px;background:${col}22;
border:1px solid ${col}55;color:${col};font-weight:600">
<span style="width:6px;height:6px;border-radius:50%;background:${col};display:inline-block"></span>
${_esc(name)}: ${_esc(lbl)}
</span>`;
}).join('')}
</div>
</div>
`;
}
}
// Zecken
if (d.zecken != null) {
const [tickLabel, tickColor] = _tickLevel(d.zecken);
html += `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius);
background:${tickColor}1a;border:1px solid ${tickColor}55;
margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.3rem;height:1.3rem;color:#92400E"><use href="/icons/phosphor.svg#bug"></use></svg>
<div style="flex:1">
<span style="font-size:var(--text-sm);font-weight:600">Zecken-Risiko: </span>
<span style="font-size:var(--text-sm);color:${tickColor};font-weight:700">
${_esc(tickLabel)}
</span>
</div>
</div>
`;
}
// Wenn keine Hunde-Daten vorhanden
if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm
&& !d.zecken && !(pollen && Object.keys(pollen).length)) {
html += `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Keine besonderen Hinweise für heute.
</p>
`;
}
el.innerHTML = html;
}
// ----------------------------------------------------------
// HILFSFUNKTIONEN — Wetter
// ----------------------------------------------------------
function _beaufort(kmh) {
if (kmh < 2) return 'Windstille';
if (kmh < 12) return 'leicht';
if (kmh < 29) return 'mäßig';
if (kmh < 50) return 'frisch';
if (kmh < 62) return 'stark';
if (kmh < 75) return 'stürmisch';
return 'Sturm';
}
function _uvLabel(uv) {
if (uv <= 2) return ['niedrig', '#4CAF50'];
if (uv <= 5) return ['mittel', '#FFC107'];
if (uv <= 7) return ['hoch', '#FF9800'];
if (uv <= 10) return ['sehr hoch', '#F44336'];
return ['extrem', '#9C27B0'];
}
function _compass(deg) {
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
return dirs[Math.round(deg / 45) % 8];
}
function _asphaltLevel(temp) {
if (temp < 40) return ['Pfoten sicher', '#4CAF50', ''];
if (temp < 50) return ['leicht erwärmt', '#FFC107',
'Kurze Kontaktzeiten sind unbedenklich.'];
if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800',
'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.'];
return ['GEFAHR — Verbrennungsgefahr!', '#F44336',
'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!'];
}
function _pollenColor(level) {
if (level === 0) return '#9E9E9E';
if (level === 1) return '#4CAF50';
if (level === 2) return '#FFC107';
if (level === 3) return '#FF9800';
return '#F44336'; // level 4+
}
function _tickLevel(risk) {
const r = (risk || '').toLowerCase();
if (r === 'niedrig') return ['niedrig', '#4CAF50'];
if (r === 'mittel') return ['mittel', '#FF9800'];
return ['hoch', '#F44336'];
}
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 };
})();

View file

@ -255,6 +255,15 @@ window.Page_wiki = (() => {
<option value="">Alle Gruppen</option>
</select>
</div>
<div style="padding:0 0 var(--space-3)">
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
style="font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Welche Rasse ist das? Foto analysieren
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="wiki-rasse-foto-input" style="display:none">
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
@ -264,6 +273,9 @@ window.Page_wiki = (() => {
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Rassen-Erkennung per KI
_bindWikiRasseErkennung(el);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
@ -1265,6 +1277,130 @@ window.Page_wiki = (() => {
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
// ----------------------------------------------------------
function _bindWikiRasseErkennung(el) {
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
const fileInput = el.querySelector('#wiki-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
return;
}
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origHtml;
_showWikiRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
}
});
}
function _showWikiRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
${r.wiki_slug ? `
<div style="margin-top:var(--space-3)">
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
Im Wiki nachschlagen
</button>
</div>` : ''}
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('wiki-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
});
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

1071
backend/static/js/worlds.js Normal file

File diff suppressed because it is too large Load diff