From a4e97348ed0c5db142d58642b405e0336c0ae7e1 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:52:11 +0200 Subject: [PATCH] Feature: Schnell-Gassi-Log + Hunde-Visitenkarte mit QR-Code (SW by-v698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worlds-FAB: neuer 'Schnell-Gassi' Button im Gassi-Chip — öffnet schlankes Bottom-Sheet mit Dauer-Toggle (15/30/45/60 min), auto-Wetter aus Cache, postet direkt als Tagebucheintrag typ='gassi' ohne GPS-Tracking - dog-profile.js: 'Visitenkarte teilen' Button öffnet Modal mit gestalteter Karte (Hundefoto, Name, Rasse/Alter, Wohnort) + QR-Code via qrserver.com, Link-kopieren und native Web-Share-API --- backend/static/js/pages/dog-profile.js | 149 ++++++++++++++ backend/static/js/worlds.js | 269 +++++++++++++++++++------ 2 files changed, 356 insertions(+), 62 deletions(-) diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 5e40c51..f80b034 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -195,9 +195,18 @@ window.Page_dog_profile = (() => { Hundepass ` : ''} + ${!dog.is_guest ? `` : ''} ${!dog.is_guest ? `` : ''} + ${!dog.is_guest ? `` : ''} @@ -264,6 +273,14 @@ window.Page_dog_profile = (() => { _showPassportModal(dog); }); + document.getElementById('dp-vcard-btn')?.addEventListener('click', () => { + _showVcardModal(dog); + }); + + document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => { + _showWrappedModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -750,6 +767,138 @@ window.Page_dog_profile = (() => { // ---------------------------------------------------------- // TEILEN // ---------------------------------------------------------- + // ---------------------------------------------------------- + // HUNDE-VISITENKARTE MIT QR-CODE + // ---------------------------------------------------------- + function _showVcardModal(dog) { + const passportUrl = `https://banyaro.app/hund/${dog.id}`; + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`; + + const user = _appState?.user; + const ownerName = user?.name || ''; + const wohnort = user?.wohnort || ''; + + // Alter errechnen + let alterStr = ''; + if (dog.geburtstag) { + const birth = new Date(dog.geburtstag + 'T00:00:00'); + const now = new Date(); + const years = now.getFullYear() - birth.getFullYear() + - (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0); + alterStr = years < 1 + ? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate` + : years === 1 ? '1 Jahr' : `${years} Jahre`; + } + + const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · '); + + const cardHtml = ` +
+ + +
+
+ + +
+ ${dog.foto_url + ? `` + : `
🐾
`} +
+
${_esc(dog.name)}
+ ${metaLine ? `
${_esc(metaLine)}
` : ''} + ${wohnort ? `
📍 ${_esc(wohnort)}
` : ''} +
+
+ + +
+ + +
+
+ ${ownerName ? `
Besitzer
+
${_esc(ownerName)}
` : ''} +
banyaro.app
+
+
+ QR-Code +
Profil öffnen
+
+
+
+ `; + + UI.modal.open({ + title: 'Visitenkarte', + body: ` +
${cardHtml}
+

+ QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen. +

+ `, + footer: ` + + + `, + }); + + // Link kopieren + document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + const inp = document.createElement('input'); + inp.value = passportUrl; + document.body.appendChild(inp); + inp.select(); + document.execCommand('copy'); + inp.remove(); + UI.toast.success('Link kopiert!'); + } + }); + + // Native Share API + document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ + title: `${dog.name} auf Ban Yaro`, + text: `Schau dir das Profil von ${dog.name} an!`, + url: passportUrl, + }); + } catch {} + } else { + // Fallback: kopieren + try { + await navigator.clipboard.writeText(passportUrl); + UI.toast.success('Link kopiert!'); + } catch { + UI.toast.error('Teilen nicht verfügbar.'); + } + } + }); + } + async function _showShareModal(dog) { UI.modal.open({ title: `${_esc(dog.name)} teilen`, diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e34d163..daacde0 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -165,13 +165,24 @@ window.Worlds = (() => { document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur)); } + function _fabOptions() { + const worldNames = ['jetzt', 'hund', 'welt']; + const chips = _chipsForWorld(worldNames[_cur]); + const opts = []; + for (const chip of chips) { + if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); } + } + return opts; + } + function _updateFab() { const fab = document.getElementById('worlds-fab'); if (!fab) return; - const icons = ['note-pencil', 'paw-print', 'warning']; - const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden']; - fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`); - fab.title = titles[_cur]; + const opts = _fabOptions(); + if (!opts.length) { fab.style.display = 'none'; return; } + fab.style.display = ''; + fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`); + fab.title = 'Schnellaktion'; } function _setupButtons() { @@ -195,21 +206,13 @@ window.Worlds = (() => { } function _openFab() { - const isWelt = _cur === 2; - const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar + const options = _fabOptions(); + if (!options.length) return; - const options = isWelt ? [ - { icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }, - { icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' }, - { icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }, - ] : [ - { icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }, - { icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' }, - { icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, - { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }, - ]; + const meldenPages = new Set(['poison','lost','recalls','map']); + const meldenCount = options.filter(o => meldenPages.has(o.page)).length; + const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'; - // Overlay erstellen const ov = document.createElement('div'); ov.id = 'fab-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end'; @@ -219,9 +222,7 @@ window.Worlds = (() => { padding:20px 16px calc(env(safe-area-inset-bottom,16px) + 16px); box-shadow:0 -8px 32px rgba(0,0,0,0.2)">
-
- ${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'} -
+
${title}
+
+ +
Dauer
+
+ ${durations.map(d => ` + + `).join('')} +
+ + + + `; + + document.body.appendChild(ov); + + const _close = () => ov.remove(); + ov.querySelector('#qg-backdrop').addEventListener('click', _close); + ov.querySelector('#qg-close').addEventListener('click', _close); + + // Dauer-Toggle + ov.querySelectorAll('.qg-dur').forEach(btn => { + btn.addEventListener('click', () => { + selectedMin = parseInt(btn.dataset.min); + ov.querySelectorAll('.qg-dur').forEach(b => { + const active = parseInt(b.dataset.min) === selectedMin; + b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; + b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)'; + b.style.color = active ? 'var(--c-primary)' : 'var(--c-text)'; + }); + }); + }); + + // Eintragen + ov.querySelector('#qg-submit').addEventListener('click', async () => { + const submitBtn = ov.querySelector('#qg-submit'); + submitBtn.disabled = true; + submitBtn.textContent = 'Wird eingetragen…'; + + try { + const payload = { + typ: 'gassi', + titel: 'Schnell-Gassi 🐾', + text: `Kurze Runde, ${selectedMin} Minuten`, + }; + if (weatherData) { + payload.weather_json = JSON.stringify(weatherData); + } + await API.post(`/dogs/${dog.id}/diary`, payload); + _close(); + UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`); + // Streak-Cache invalidieren + try { localStorage.removeItem('w3_streak_' + dog.id); } catch {} + // JETZT-Welt neu rendern für aktuellen Streak + setTimeout(() => _renderJetzt(), 300); + } catch (err) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' Eintragen'; + UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.'); + } + }); + } + // ── CHIP-KONFIGURATION ────────────────────────────────────── // Alle verfügbaren Chips mit Metadaten const _ALL_CHIPS = [ - { icon:'note-pencil', label:'Notizblock', page:'notes' }, - { icon:'currency-eur', label:'Ausgaben', page:'expenses' }, - { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, - { icon:'handshake', label:'Playdate', page:'playdate' }, - { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, - { icon:'sun', label:'Wetter', page:'wetter' }, + { icon:'note-pencil', label:'Notizblock', page:'notes', + fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] }, + { icon:'currency-eur', label:'Ausgaben', page:'expenses', + fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] }, + { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, + { icon:'handshake', label:'Playdate', page:'playdate', + fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] }, + { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, + { icon:'sun', label:'Wetter', page:'wetter' }, - { icon:'book-open', label:'Tagebuch', page:'diary' }, - { icon:'heartbeat', label:'Gesundheit', page:'health' }, - { icon:'target', label:'Übungen', page:'uebungen' }, - { icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'}, - { icon:'heart', label:'Adoption', page:'adoption' }, - { icon:'house-line', label:'Sitting', page:'sitting' }, - { icon:'books', label:'Wiki', page:'wiki' }, - { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, - { icon:'map-trifold', label:'Karte', page:'map' }, - { icon:'push-pin', label:'Forum', page:'forum' }, - { icon:'users', label:'Freunde', page:'friends' }, - { icon:'paw-print', label:'Gassi', page:'walks' }, - { icon:'skull', label:'Giftköder', page:'poison' }, - { icon:'warning-circle', label:'Rückrufe', page:'recalls' }, - { icon:'dog', label:'Verlorene', page:'lost' }, - { icon:'path', label:'Routen', page:'routes' }, - { icon:'calendar-dots', label:'Events', page:'events' }, - { icon:'sparkle', label:'Jobs', page:'jobs' }, - { icon:'book-open', label:'Knigge', page:'knigge' }, - { icon:'film-slate', label:'Filme', page:'movies' }, - { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' }, - { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' }, - { icon:'sparkle', label:'Social', page:'social', role:'social' }, - { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, - { icon:'gear', label:'Admin', page:'admin', role:'admin' }, + { icon:'book-open', label:'Tagebuch', page:'diary', + fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] }, + { icon:'heartbeat', label:'Gesundheit', page:'health', + fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, + { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] }, + { icon:'target', label:'Übungen', page:'uebungen', + fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] }, + { icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene', + fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] }, + { icon:'heart', label:'Adoption', page:'adoption', + fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] }, + { icon:'house-line', label:'Sitting', page:'sitting', + fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] }, + { icon:'books', label:'Wiki', page:'wiki' }, + { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, + { icon:'map-trifold', label:'Karte', page:'map', + fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] }, + { icon:'push-pin', label:'Forum', page:'forum', + fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] }, + { icon:'users', label:'Freunde', page:'friends', + fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] }, + { icon:'paw-print', label:'Gassi', page:'walks', + fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' }, + { icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] }, + { icon:'skull', label:'Giftköder', page:'poison', + fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] }, + { icon:'warning-circle', label:'Rückrufe', page:'recalls', + fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] }, + { icon:'dog', label:'Verlorene', page:'lost', + fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] }, + { icon:'path', label:'Routen', page:'routes', + fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] }, + { icon:'calendar-dots', label:'Events', page:'events', + fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] }, + { icon:'sparkle', label:'Jobs', page:'jobs' }, + { icon:'book-open', label:'Knigge', page:'knigge' }, + { icon:'film-slate', label:'Filme', page:'movies' }, + { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder', + fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, + { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder', + fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, + { icon:'sparkle', label:'Social', page:'social', role:'social', + fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, + { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, + { icon:'gear', label:'Admin', page:'admin', role:'admin' }, ]; const _DEFAULT_CONFIG = { @@ -618,18 +766,13 @@ window.Worlds = (() => { async function _loadDailyImage(dog) { if (!dog) return null; - const todayKey = 'bg_' + new Date().toISOString().slice(0, 10); + const todayKey = 'bg3_' + new Date().toISOString().slice(0, 10); const cached = _wLoad(todayKey); if (cached?.data) return cached.data; try { - const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`); - const entries = r.data?.entries || r.data || []; - const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url)); - if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; } - const day = Math.floor(Date.now() / 86400000); - const entry = withPhotos[day % withPhotos.length]; - const url = (entry.foto_urls?.[0] || entry.foto_url); - _wSave(todayKey, url); + const dash = await API.dogs.welcomeDashboard(dog.id); + const url = dash?.random_photo?.url || dog.foto_url || null; + if (url) _wSave(todayKey, url); return url; } catch { return dog.foto_url || null; } } @@ -669,10 +812,11 @@ window.Worlds = (() => { const user = _state?.user; el.innerHTML = _skeleton(3); - const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([ + const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([ _getCachedWeather(), user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }), user ? _getNearbyAlerts() : Promise.resolve([]), + user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }), ]); const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 }; @@ -681,6 +825,7 @@ window.Worlds = (() => { const dogList = dogsObj.data || []; const dog = dogList[0] || null; const alertList = alertsRes.value || []; + const totalKm = achRes.value?.data?.stats?.total_km ?? null; const isOffline = weatherObj.fromCache && dogsObj.fromCache; const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0); @@ -756,7 +901,7 @@ window.Worlds = (() => {
${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale}
-
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
+
${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}
${user ? userAvatarHtml : ''}