Feature: Schnell-Gassi-Log + Hunde-Visitenkarte mit QR-Code (SW by-v698)

- 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
This commit is contained in:
rene 2026-05-04 20:52:11 +02:00
parent 6e4bf25581
commit a4e97348ed
2 changed files with 356 additions and 62 deletions

View file

@ -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)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div style="font-size:var(--text-base);font-weight:700">
${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'}
</div>
<div style="font-size:var(--text-base);font-weight:700">${title}</div>
<button id="fab-close" style="background:var(--c-border);border:none;border-radius:50%;
width:28px;height:28px;cursor:pointer;display:flex;align-items:center;justify-content:center">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
@ -260,6 +261,10 @@ window.Worlds = (() => {
_close();
const page = btn.dataset.page;
const action = btn.dataset.action;
if (action === 'quickGassi') {
_openQuickGassi();
return;
}
navigateTo(page);
if (action === 'openNew') {
setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400);
@ -268,42 +273,185 @@ window.Worlds = (() => {
});
}
// ── SCHNELL-GASSI ─────────────────────────────────────────────
async function _openQuickGassi() {
const dog = _dogs[_dogIdx] || null;
if (!dog) {
UI.toast?.error('Kein Hund gefunden. Bitte zuerst ein Profil anlegen.');
navigateTo('dog-profile');
return;
}
// Wetter aus Cache holen (kein Wait nötig)
let weatherData = null;
try {
const wc = _wLoad('weather');
if (wc?.data) weatherData = wc.data;
} catch {}
let selectedMin = 30;
const durations = [15, 30, 45, 60];
const ov = document.createElement('div');
ov.id = 'quick-gassi-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:400;display:flex;flex-direction:column;justify-content:flex-end';
const weatherLine = weatherData
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:6px">
🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
</div>` : '';
ov.innerHTML = `
<div id="qg-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(3px)"></div>
<div style="position:relative;z-index:1;background:var(--c-bg);border-radius:24px 24px 0 0;
padding:24px 16px calc(env(safe-area-inset-bottom,16px) + 20px);
box-shadow:0 -8px 32px rgba(0,0,0,0.25)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div>
<div style="font-size:var(--text-base);font-weight:700">🐾 Schnell-Gassi</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${_esc(dog.name)} · ohne GPS
</div>
${weatherLine}
</div>
<button id="qg-close" style="background:var(--c-border);border:none;border-radius:50%;
width:32px;height:32px;cursor:pointer;display:flex;align-items:center;justify-content:center">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div style="font-size:var(--text-sm);font-weight:600;margin-bottom:10px">Dauer</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px">
${durations.map(d => `
<button class="qg-dur" data-min="${d}"
style="padding:12px 6px;border-radius:12px;border:2px solid ${d === selectedMin ? 'var(--c-primary)' : 'var(--c-border)'};
background:${d === selectedMin ? 'var(--c-primary-subtle)' : 'var(--c-bg-card)'};
cursor:pointer;font-weight:700;font-size:var(--text-sm);
color:${d === selectedMin ? 'var(--c-primary)' : 'var(--c-text)'}">
${d} min
</button>
`).join('')}
</div>
<button id="qg-submit" style="width:100%;padding:16px;border-radius:14px;
background:var(--c-primary);color:white;border:none;cursor:pointer;
font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;justify-content:center;gap:8px">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg>
Eintragen
</button>
</div>
`;
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 = '<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg> 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 = (() => {
<div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
</div>
${user ? userAvatarHtml : ''}
</div>