Feature: Welten-Onboarding, Wetter-Motivation, UX-Fixes (SW by-v715)

Welten (worlds.js):
- Swipe-Hints beim ersten Öffnen (JETZT ← → WELT animiert, einmalig)
- Kein-Hund-Onboarding: Feature-Preview-Grid statt leerer Karte
- Hintergrund-Foto-Hint: Kamera-Karte wenn noch kein Tagebuchfoto
- worlds-back: navigiert zu Welcome wenn kein User eingeloggt
- Nach Logout: worlds-back Button sofort ausgeblendet

Wetter (wetter.js):
- Standort-Fehlerseite zu Motivations-Seite umgebaut
- Feature-Preview: Gassi-Score, 7-Tage, Regenradar, Rekorde
- CTA: Standort freigeben + Registrieren (nur für Gäste)

Settings (settings.js):
- Logo in Auth-Form: display:block + margin:0 auto zentriert
- Header bleibt sichtbar (FAB/Zurück-Navigation funktioniert)

Jobs (jobs.js):
- 2-Spalten-Grid auf Mobile: auto-fit statt festes 1fr 1fr
- Kein doppeltes Padding im Wrapper

Backend:
- weather.py, achievements.py: diary JOIN fix (d.user_id → dogs JOIN)
- Neue Wetter-Badges: wetter_tapfer, jahreszeiten, schnee
- Ernährungs-, Reise-, Ausgaben-Seite: diverse UX-Verbesserungen
- Presse-Seite erweitert
- Ban Yaro Foto-Assets (WebP + HIRES JPG)
This commit is contained in:
rene 2026-05-05 17:32:03 +02:00
parent aa4849d947
commit 55069d246b
28 changed files with 719 additions and 198 deletions

View file

@ -13,6 +13,7 @@ window.Worlds = (() => {
let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
// Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -49,6 +50,55 @@ window.Worlds = (() => {
_setupButtons();
_goTo(_cur, false);
show();
_showSwipeHints();
}
function _showSwipeHints() {
if (localStorage.getItem('worlds_swipe_seen')) return;
localStorage.setItem('worlds_swipe_seen', '1');
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
const hint = document.createElement('div');
hint.style.cssText = [
'position:absolute;inset:0;pointer-events:none;z-index:55',
'display:flex;align-items:center;justify-content:space-between',
'padding:0 8px;transition:opacity 1s ease',
].join(';');
const arrowStyle = `
display:flex;flex-direction:column;align-items:center;gap:4px;
background:rgba(0,0,0,0.42);backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,0.18);border-radius:14px;
padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate;
`;
hint.innerHTML = `
<style>
@keyframes worlds-pulse {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(-3px); }
}
.wsh-right { animation-name:worlds-pulse-r !important; }
@keyframes worlds-pulse-r {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(3px); }
}
</style>
<div style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">JETZT</span>
</div>
<div class="wsh-right" style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32l-80 80a8 8 0 0 1-11.32-11.32L164.69 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">WELT</span>
</div>
`;
ov.appendChild(hint);
setTimeout(() => { hint.style.opacity = '0'; }, 2800);
setTimeout(() => hint.remove(), 3900);
}
function show(worldIdx) {
@ -187,7 +237,10 @@ window.Worlds = (() => {
function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
document.getElementById('worlds-back')?.addEventListener('click', () => show());
document.getElementById('worlds-back')?.addEventListener('click', () => {
if (_state?.user) show();
else if (window.App) window.App.navigate('welcome');
});
document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto';
dot.addEventListener('click', () => {
@ -414,7 +467,7 @@ window.Worlds = (() => {
{ 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',
{ icon:'list-checks', label:'Trainingsplä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' }] },
@ -444,7 +497,7 @@ window.Worlds = (() => {
{ 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:'tree-structure', label:'Zuchtkartei', 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' }] },
@ -452,12 +505,19 @@ window.Worlds = (() => {
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' },
// ── NEUE FEATURES ────────────────────────────────────────────
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung',
fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] },
{ icon:'airplane', label:'Reise', page:'reise' },
{ icon:'smiley', label:'Persönlichkeit', page:'personality' },
];
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'litters','zuchthunde','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
@ -605,10 +665,11 @@ window.Worlds = (() => {
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;
border-radius:50%;background:#EF4444;border:none;cursor:pointer;
display:flex;align-items:center;justify-content:center;z-index:2">
<svg class="ph-icon" style="width:9px;height:9px;color:white">
style="position:absolute;top:-8px;right:-8px;width:24px;height:24px;
border-radius:50%;background:#EF4444;border:2px solid rgba(18,22,32,0.9);
cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 6px rgba(0,0,0,0.5)">
<svg class="ph-icon" style="width:13px;height:13px;color:white">
<use href="/icons/phosphor.svg#x"></use>
</svg>
</button>` : `
@ -781,11 +842,19 @@ window.Worlds = (() => {
const track = document.getElementById('worlds-track');
if (!track) return;
if (url) {
const img = new Image();
img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; };
img.onerror = () => _applyBgImage(null);
img.src = url;
const toLoad = new Image();
toLoad.onload = () => {
_hasBgPhoto = true;
track.style.backgroundImage = `url('${url}')`;
track.style.backgroundSize = '100% auto';
track.style.backgroundPosition = '0 40%';
track.style.backgroundRepeat = 'no-repeat';
document.getElementById('wh-photo-hint')?.remove();
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
} else {
_hasBgPhoto = false;
track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
track.style.backgroundSize = '100% 100%';
}
@ -839,26 +908,29 @@ window.Worlds = (() => {
const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
const stale = isOffline && staleMin > 5
const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
const weatherLine = w
? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen`
: '';
// Streak für 3er-Chip-Zeile
let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)';
if (user && dog) {
try {
const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`);
const s = sr.data;
const streak = s?.current_streak || 0;
const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10);
streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)');
streakVal = streak > 0
? (trainedToday ? `${streak} Tage` : `🔥 ${streak} Tage`)
: (trainedToday ? '✓ Heute' : 'Heute starten');
} catch {}
// Gassi-Score aus Wetterdaten berechnen
function _calcGassiScore(wd) {
if (!wd) return null;
let s = 10;
const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0;
if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1;
if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1;
if (wind > 60) s -= 2; else if (wind > 40) s -= 1;
if (wd.thunderstorm) s -= 3;
return Math.max(1, Math.min(10, s));
}
const gassiScore = _calcGassiScore(w);
const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444';
const weatherEmoji = !w ? '🌤️'
: w.thunderstorm ? '⛈️'
: (w.precip_prob ?? 0) > 70 ? '🌧️'
: (w.precip_prob ?? 0) > 30 ? '🌦️'
: (w.temp_c ?? 20) > 28 ? '☀️🔥'
: (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️';
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
@ -901,7 +973,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 : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
<div class="world-info-sub">${_esc(dayStr)}</div>
</div>
${user ? userAvatarHtml : ''}
</div>
@ -909,17 +981,25 @@ window.Worlds = (() => {
${alertHtml}
${user && dog ? `
<div class="wj-chip-row">
<div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${streakCol}">
<use href="/icons/phosphor.svg#target"></use></svg>
<span class="wj-chip-label">Streak</span>
<span class="wj-chip-val">${streakVal}</span>
<div class="wj-chip" data-wnav="wetter" style="${gassiScore ? `border-color:${gassiColor}44;background:${gassiColor}12;` : ''}">
<div style="display:flex;align-items:center;gap:6px;width:100%">
<span style="font-size:1.4rem;line-height:1;flex-shrink:0">${weatherEmoji}</span>
<div style="flex:1;min-width:0">
<div style="font-size:9px;color:rgba(255,255,255,0.55);font-weight:600;letter-spacing:.05em;text-transform:uppercase">Gassi-Score</div>
<div style="display:flex;align-items:baseline;gap:3px;margin-top:1px">
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.5);margin-top:1px">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>` : ''}
</div>
</div>
</div>
<div class="wj-chip" data-wnav="routes">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#path"></use></svg>
<span class="wj-chip-label">Gassirunde</span>
<span class="wj-chip-val" id="wj-route-val"></span>
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div>
<div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
@ -1018,13 +1098,41 @@ window.Worlds = (() => {
const dogs = dogsRes.data || [];
if (!dogs.length) {
const features = [
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
{ icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' },
{ icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' },
];
el.innerHTML = `
<div class="world-info-card" style="text-align:center">
<div style="font-size:4rem;margin-bottom:12px">🐶</div>
<div class="world-info-title">Noch kein Hund angelegt</div>
<div class="world-info-sub" style="margin-bottom:20px">Erstelle das Profil deines Hundes</div>
<button class="btn btn-primary" onclick="Worlds.navigateTo('dog-profile')">Hund anlegen</button>
<div class="world-top">
<div class="world-info-card" style="text-align:center">
<div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
<div class="world-info-title">Dein Hund wartet!</div>
<div class="world-info-sub" style="margin-bottom:16px">
Lege ein Profil an und schalte alle Features frei
</div>
<button class="btn btn-primary" style="width:100%" onclick="Worlds.navigateTo('dog-profile')">
Hund anlegen
</button>
</div>
</div>
<div class="world-bottom">
<div class="world-section-label">Was dich erwartet</div>
<div class="world-chips-grid">
${features.map(f => `
<div class="world-chip" style="opacity:0.7;cursor:default">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:${f.color}">
<use href="/icons/phosphor.svg#${f.icon}"></use>
</svg>
<span class="world-chip-label">${f.title}</span>
</div>
`).join('')}
</div>
</div>`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
return;
}
@ -1093,6 +1201,25 @@ window.Worlds = (() => {
</div>
</div>
<div class="world-bottom">
${!_hasBgPhoto ? `
<div id="wh-photo-hint" data-wnav="diary"
style="background:rgba(0,0,0,0.32);backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);
border-radius:16px;padding:11px 14px;display:flex;align-items:center;
gap:10px;cursor:pointer;color:white;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:rgba(196,132,58,0.9);flex-shrink:0">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
<div>
<div style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
Hintergrund-Foto hinzufügen
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos erscheinen hier als Panorama
</div>
</div>
</div>
` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}