Release v1.5.0

This commit is contained in:
rene 2026-05-06 19:31:35 +02:00
commit ed9c482ae5
17 changed files with 837 additions and 267 deletions

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '727'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.4.0'; // ← semantische Version, wird bei make release gesetzt
const APP_VER = '741'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
const App = (() => {
@ -43,7 +43,7 @@ const App = (() => {
routes: { title: 'Routen', module: null },
events: { title: 'Events', module: null },
poison: { title: 'Giftköder-Alarm', module: null },
walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true },
walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true, requiresPro: true },
sitting: { title: 'Sitting', module: null, requiresAuth: true },
forum: { title: 'Forum', module: null },
wiki: { title: 'Wiki', module: null },
@ -51,12 +51,12 @@ const App = (() => {
movies: { title: 'Filme', module: null },
trainingsplaene: { title: 'Trainingspläne', module: null },
uebungen: { title: 'Übungsbibliothek', module: null },
notes: { title: 'Notizblock', module: null, requiresAuth: true },
notes: { title: 'Notizblock', module: null, requiresAuth: true, requiresPro: true },
'erste-hilfe': { title: 'Erste Hilfe', module: null },
settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null, requiresAuth: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true },
moderation: { title: 'Moderation', module: null, requiresAuth: true },
@ -74,14 +74,28 @@ const App = (() => {
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 },
playdate: { title: 'Playdate', module: null, requiresAuth: true, requiresPro: true },
wetter: { title: 'Wetter', module: null },
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true, requiresPro: true },
personality: { title: 'Persönlichkeitstest', module: null },
reise: { title: 'Reise mit Hund', module: null },
reise: { title: 'Reise mit Hund', module: null, requiresPro: true },
hilfe: { title: 'Hilfe & FAQ', module: null },
};
// ----------------------------------------------------------
// TIER-CHECK — Frontend-Pendant zu has_pro_access() in auth.py
// ----------------------------------------------------------
function _hasPro(user) {
if (!user) return false;
const t = user.subscription_tier || 'standard';
// _test-Tiers simulieren ihren Tier ohne Admin-Override — so sieht Admin was echte User sehen
if (t.endsWith('_test')) return ['pro_test','breeder_test'].includes(t);
// Normale Prüfung: Admin/Mod/Social bekommen immer Pro
if (user.rolle === 'admin' || user.rolle === 'moderator') return true;
if (user.is_moderator || user.is_social_media) return true;
return ['pro','breeder'].includes(t);
}
// ----------------------------------------------------------
// AUTH GUARD — Login-Gate Texte pro Seite
// ----------------------------------------------------------
@ -142,6 +156,34 @@ const App = (() => {
return;
}
// Pro-Guard — nur wenn User eingeloggt aber kein Pro-Zugang
if (page.requiresPro && state.user && !_hasPro(state.user)) {
const container = document.querySelector(`#page-${pageId} .page-body`);
if (container) {
container.innerHTML = `
<div style="max-width:420px;margin:40px auto;padding:var(--space-6) var(--space-4);text-align:center">
<div style="font-size:3rem;margin-bottom:var(--space-4)"></div>
<h2 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-3)">Ban Yaro Pro</h2>
<p style="color:var(--c-text-secondary);margin:0 0 var(--space-6);line-height:1.6">
Dieses Feature ist Teil von Ban Yaro Pro verfügbar wenn wir die nächste Stufe zünden.<br>
Du wirst benachrichtigt wenn es soweit ist.
</p>
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);text-align:left;font-size:var(--text-sm);color:var(--c-text-secondary)">
<div style="font-weight:700;margin-bottom:var(--space-2);color:var(--c-text)">Ban Yaro Pro enthält:</div>
<ul style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1)">
<li>Mehrere Hunde verwalten</li>
<li>KI-Trainer für personalisiertes Training</li>
<li>Direktnachrichten &amp; Freunde</li>
<li>Gassi-Treffen, Playdate, Ernährung, Reise</li>
<li>Notizblock</li>
</ul>
</div>
</div>`;
}
return;
}
if (page.module) {
const hasParams = params && Object.keys(params).length > 0;
if (hasParams) {
@ -819,6 +861,7 @@ const App = (() => {
try { localStorage.removeItem('by_wissen_open'); } catch (_) {}
_initVersionCheck();
await _checkAuth();
// Einladungslink /teilen/{token} → direkt annehmen
@ -916,6 +959,124 @@ const App = (() => {
});
}
// ----------------------------------------------------------
// ----------------------------------------------------------
// VERSION-CHECK — persistentes Banner wenn neue Version verfügbar
// ----------------------------------------------------------
let _updateBannerShown = false;
async function _checkVersion() {
try {
const r = await fetch('/api/version', { cache: 'no-store' });
if (!r.ok) return;
const { version } = await r.json();
if (version && version !== APP_VER && !_updateBannerShown) {
_updateBannerShown = true;
_showUpdateBanner(version);
}
} catch { /* offline — ignorieren */ }
}
function _showUpdateBanner(newVersion) {
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const existing = document.getElementById('app-update-banner');
if (existing) return;
const banner = document.createElement('div');
banner.id = 'app-update-banner';
banner.style.cssText = [
'position:fixed;bottom:calc(env(safe-area-inset-bottom,0px) + 72px);left:12px;right:12px',
'z-index:9000;background:var(--c-primary);color:#fff;border-radius:16px',
'padding:14px 16px;box-shadow:0 4px 20px rgba(0,0,0,0.3)',
'display:flex;flex-direction:column;gap:10px',
].join(';');
banner.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
<div>
<div style="font-weight:700;font-size:var(--text-sm)">
Neue Version verfügbar (v${newVersion})
</div>
<div style="font-size:var(--text-xs);opacity:0.85;margin-top:2px">
Tippe auf Aktualisieren um die neueste Version zu laden.
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
<button id="upd-btn-reload"
style="background:rgba(255,255,255,0.2);border:1px solid rgba(255,255,255,0.4);
color:#fff;border-radius:10px;padding:8px 14px;cursor:pointer;
font-size:var(--text-sm);font-weight:700;white-space:nowrap">
Aktualisieren
</button>
<button id="upd-btn-close"
style="background:none;border:none;color:rgba(255,255,255,0.7);
cursor:pointer;font-size:1.1rem;padding:4px 6px;line-height:1"></button>
</div>
</div>
<div id="upd-ios-hint" style="display:none;font-size:var(--text-xs);
background:rgba(0,0,0,0.2);border-radius:10px;padding:10px 12px;line-height:1.6">
${isIos
? `Falls die App nach dem Aktualisieren noch die alte Version zeigt:<br>
<strong>1.</strong> Drücke lange auf das App-Icon am Homescreen<br>
<strong>2.</strong> Wähle App entfernen" (nur das Symbol, keine Daten)<br>
<strong>3.</strong> Öffne banyaro.app in Safari und füge die App erneut hinzu`
: `Falls die Seite noch die alte Version zeigt:<br>
Drücke <strong>Cmd+Shift+R</strong> (Mac) bzw. <strong>Ctrl+Shift+R</strong> (Windows/Android Chrome) für einen harten Reload.`}
</div>
`;
document.body.appendChild(banner);
banner.querySelector('#upd-btn-close').addEventListener('click', () => banner.remove());
banner.querySelector('#upd-btn-reload').addEventListener('click', async () => {
const btn = banner.querySelector('#upd-btn-reload');
btn.textContent = 'Lädt…';
btn.disabled = true;
sessionStorage.setItem('by_update_reload', APP_VER);
try {
// SW aktivieren + alle Caches leeren für sauberen Reload
const reg = await navigator.serviceWorker?.getRegistration();
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
await reg?.update();
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
} catch { /* ignorieren */ }
setTimeout(() => location.reload(), 600);
});
}
function _initVersionCheck() {
// Beim Start nach 10 Sekunden prüfen (nicht sofort — Prio für Auth)
setTimeout(_checkVersion, 10_000);
// Dann alle 30 Minuten
setInterval(_checkVersion, 30 * 60_000);
// Beim Wiedereinstieg in die App
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') _checkVersion();
});
// Nach Reload: war das ein Update-Reload? Falls Version immer noch alt → iOS-Hinweis
const reloadVer = sessionStorage.getItem('by_update_reload');
if (reloadVer && reloadVer === APP_VER) {
// Version hat sich nicht geändert nach Reload → iOS-Cache-Problem
sessionStorage.removeItem('by_update_reload');
setTimeout(() => {
fetch('/api/version', { cache: 'no-store' })
.then(r => r.json())
.then(({ version }) => {
if (version && version !== APP_VER) {
_updateBannerShown = true;
_showUpdateBanner(version);
// iOS-Hinweis sofort aufklappen
setTimeout(() => {
document.getElementById('upd-ios-hint')?.style.setProperty('display', 'block');
}, 300);
}
}).catch(() => {});
}, 2000);
}
}
// ----------------------------------------------------------
// ÖFFENTLICHE API
// (andere Module können App.state, App.navigate etc. nutzen)

View file

@ -795,6 +795,9 @@ window.Page_admin = (() => {
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
${_esc(u.rolle)}
</span>
· <span style="color:${u.subscription_tier && u.subscription_tier !== 'standard' ? 'var(--c-primary)' : 'var(--c-text-muted)'}">
${_esc(u.subscription_tier || 'standard')}
</span>
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
@ -823,6 +826,11 @@ window.Page_admin = (() => {
title="Rolle ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-tier" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-tier="${_esc(u.subscription_tier || 'standard')}"
title="Abo-Stufe ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#star"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
data-name="${_esc(u.name)}" title="Löschen"
style="color:var(--c-danger)">
@ -847,6 +855,9 @@ window.Page_admin = (() => {
el.querySelectorAll('.adm-rolle').forEach(btn => {
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
});
el.querySelectorAll('.adm-tier').forEach(btn => {
btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier));
});
el.querySelectorAll('.adm-delete').forEach(btn => {
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
});
@ -903,6 +914,54 @@ window.Page_admin = (() => {
});
}
async function _changeTier(uid, name, currentTier) {
const tiers = ['standard', 'pro', 'breeder', 'standard_test', 'pro_test', 'breeder_test'];
const tierLabels = {
standard: 'Standard (kostenlos)',
pro: 'Pro (bezahlt)',
breeder: 'Breeder (Züchter)',
standard_test: 'Standard Test (intern)',
pro_test: 'Pro Test (intern)',
breeder_test: 'Breeder Test (intern)',
};
UI.modal.open({
title: `Abo-Stufe ändern: ${name}`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Stufe: <strong>${currentTier}</strong>
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${tiers.map(t => `
<button class="btn ${t === currentTier ? 'btn-primary' : 'btn-secondary'} adm-tier-choice"
data-tier="${t}" form="" ${t === currentTier ? 'disabled' : ''}>
${tierLabels[t]}${t === currentTier ? ' ✓' : ''}
</button>
`).join('')}
</div>
`,
});
document.querySelectorAll('.adm-tier-choice:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
UI.modal.close();
try {
await API.patch(`/admin/users/${uid}`, { subscription_tier: btn.dataset.tier });
// Eigenes Tier geändert → User-State neu laden + Welten neu rendern
if (String(uid) === String(_appState?.user?.id)) {
_appState.user.subscription_tier = btn.dataset.tier;
if (window.Worlds) {
window.Worlds.init(_appState);
}
UI.toast.success(`Dein Tier ist jetzt ${btn.dataset.tier} — Ansicht aktualisiert.`);
} else {
UI.toast.success(`${name}: Abo-Stufe ist jetzt ${btn.dataset.tier}.`);
}
_renderTab();
} catch (e) { UI.toast.error(e.message); }
});
});
}
async function _deleteUser(uid, name) {
const ok = await UI.modal.confirm({
title: `${name} löschen?`,

View file

@ -82,6 +82,16 @@ window.Page_datenschutz = (() => {
Plattformsicherheit).
</p>`)}
${sec('Direktnachrichten', `
<p style="${S.p}">
Nachrichten zwischen Nutzern (z. B. zwischen Hundesitter und Hundeeigentümer oder
zwischen Interessenten und Züchtern) werden auf unserem Server gespeichert, bis du
das Gespräch oder deinen Account löschst. Admins können gemeldete Nachrichten zur
Missbrauchsprüfung einsehen (Art. 6 Abs. 1 lit. f DSGVO berechtigtes Interesse
an Plattformsicherheit). Nachrichten werden nicht an Dritte weitergegeben.
Du kannst Gespräche jederzeit selbst löschen.
</p>`)}
${sec('KI-Funktionen', `
<p style="${S.p}">
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
@ -99,12 +109,34 @@ window.Page_datenschutz = (() => {
<a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener"
style="${S.a}">anthropic.com/privacy</a>.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
Diese Analyse läuft auf unserem lokalen Server in Deutschland deine Trainingsdaten
verlassen dabei nicht unsere Infrastruktur. Es findet kein Training oder Fine-Tuning
von KI-Modellen auf Basis deiner Nutzerdaten statt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
findet nicht statt.
</p>`)}
${sec('Wetterdaten (Open-Meteo)', `
<p style="${S.p}">
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform), um die lokale
Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen
keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen
Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst.
Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Datenschutzerklärung von Open-Meteo:
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
style="${S.a}">open-meteo.com/en/terms</a>
</p>`)}
${sec('Routenvorschläge (OpenRouteService)', `
<p style="${S.p}">
Die Funktion <strong>Routenvorschläge"</strong> berechnet auf Wunsch einen Rundweg

View file

@ -276,6 +276,13 @@ window.Page_settings = (() => {
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? `
<div style="margin:var(--space-3) 0;padding:var(--space-3) var(--space-4);
background:rgba(196,132,58,0.1);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary)">
<strong>Ban Yaro Pro</strong> kommt bald mehr Features, mehrere Hunde.
</div>
` : ''}
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">

View file

@ -87,6 +87,14 @@ window.Page_welcome = (() => {
// LANDING PAGE — nicht eingeloggte Besucher
// ----------------------------------------------------------
function _renderLanding(isInstalled) {
// Browser-Besucher (kein PWA) ohne Login → auf /info weiterleiten
const isPWA = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
if (!isPWA && !sessionStorage.getItem('by_stay_in_app')) {
window.location.replace('/info');
return;
}
const hasPrompt = !!App.getInstallPrompt();
_container.innerHTML = `
@ -108,7 +116,7 @@ window.Page_welcome = (() => {
${hasPrompt ? `
<button class="btn wc-btn-hero" id="welcome-install-hero-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
App installieren
Zum Home-Bildschirm hinzufügen
</button>
` : `
<button class="btn wc-btn-hero" id="welcome-register-btn">
@ -218,7 +226,7 @@ window.Page_welcome = (() => {
${hasPrompt ? `
<button class="btn wc-btn-hero" id="welcome-install-hero-btn2">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
App installieren kostenlos
Zum Home-Bildschirm hinzufügen
</button>
` : `
<button class="btn wc-btn-hero" id="welcome-register-btn2">
@ -231,7 +239,7 @@ window.Page_welcome = (() => {
${!isInstalled ? `
<button class="wc-install-link" id="welcome-install-link">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Installationsanleitung
Zum Home-Bildschirm hinzufügen
</button>
` : ''}
</div>
@ -242,7 +250,7 @@ window.Page_welcome = (() => {
<div class="card wc-install-card">
<div class="wc-install-header">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
App installieren
Immer griffbereit kein App Store
</div>
<div style="padding:var(--space-4)">${_installHTML()}</div>
</div>
@ -481,7 +489,7 @@ window.Page_welcome = (() => {
<div class="card wc-install-card">
<div class="wc-install-header">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
App installieren
Immer griffbereit kein App Store
</div>
<div style="padding:var(--space-4)">${_installHTML()}</div>
</div>
@ -1069,11 +1077,11 @@ window.Page_welcome = (() => {
if (hasPrompt) {
return `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3);line-height:1.5">
Kein App Store nötig direkt auf den Home-Bildschirm.
Ban Yaro immer griffbereit einmal hinzufügen, dann direkt vom Home-Bildschirm öffnen.
</p>
<button class="btn btn-primary" id="install-android-btn" style="width:100%;margin-bottom:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Ban Yaro installieren
Zum Home-Bildschirm hinzufügen
</button>`;
}
@ -1095,7 +1103,7 @@ window.Page_welcome = (() => {
if (isIOS && !isSafari) {
return `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4);line-height:1.5">
Auf dem iPhone geht die Installation nur über <strong>Safari</strong>.
Auf dem iPhone funktioniert das Hinzufügen nur über <strong>Safari</strong>.
</p>
${_steps([
['safari-logo', 'Öffne <strong>Safari</strong> auf deinem iPhone'],

View file

@ -457,14 +457,14 @@ window.Worlds = (() => {
// Alle verfügbaren Chips mit Metadaten
const _ALL_CHIPS = [
{ icon:'note-pencil', label:'Notizblock', page:'notes',
{ icon:'note-pencil', label:'Notizblock', page:'notes', pro: true,
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',
{ icon:'handshake', label:'Playdate', page:'playdate', pro: true,
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:'chat-circle-dots', label:'Nachrichten', page:'chat', pro: true },
{ icon:'sun', label:'Wetter', page:'wetter' },
{ icon:'book-open', label:'Tagebuch', page:'diary',
@ -486,9 +486,9 @@ window.Worlds = (() => {
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',
{ icon:'users', label:'Freunde', page:'friends', pro: true,
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
{ icon:'paw-print', label:'Gassi', page:'walks',
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
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',
@ -513,9 +513,9 @@ window.Worlds = (() => {
{ 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',
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true,
fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] },
{ icon:'airplane', label:'Reise', page:'reise' },
{ icon:'airplane', label:'Reise', page:'reise', pro: true },
{ icon:'smiley', label:'Persönlichkeit', page:'personality' },
];
@ -567,13 +567,31 @@ window.Worlds = (() => {
}
function _chipAllowed(chip) {
const u = _state?.user;
const tier = u?.subscription_tier || 'standard';
const isTest = tier.endsWith('_test');
// Role-Checks (hart — komplett ausblenden)
if (!chip?.role) return true;
if (chip.role === 'breeder') return u?.rolle === 'breeder' || u?.rolle === 'admin';
if (chip.role === 'breeder') {
if (isTest) return tier === 'breeder_test';
return u?.rolle === 'breeder' || u?.rolle === 'admin';
}
if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin';
if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
if (chip.role === 'admin') return u?.rolle === 'admin';
return true;
}
// Gibt true zurück wenn User vollen Pro-Zugriff hat
function _hasProAccess() {
const u = _state?.user;
if (!u) return false;
const tier = u.subscription_tier || 'standard';
if (tier.endsWith('_test')) return ['pro_test','breeder_test'].includes(tier);
if (u.rolle === 'admin' || u.rolle === 'moderator') return true;
if (u.is_moderator || u.is_social_media) return true;
return ['pro','breeder'].includes(tier);
}
function _chipsForWorld(world) {
const pages = _getConfig()[world] || _DEFAULT_CONFIG[world];
return pages.map(_chipMeta).filter(c => c && _chipAllowed(c));
@ -869,9 +887,10 @@ window.Worlds = (() => {
// ── CHIP-HELPER ──────────────────────────────────────────────
function _chip(icon, label, page) {
function _chip(icon, label, page, locked = false) {
const style = locked ? 'opacity:0.25;cursor:default;' : '';
return `
<div class="world-chip" data-wnav="${page}">
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`} style="${style}">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
@ -1240,7 +1259,7 @@ window.Worlds = (() => {
` : ''}
<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('')}
${chips.map(c => _chip(c.icon, c.label, c.page, !!(c.pro && !_hasProAccess()))).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span>
@ -1334,7 +1353,7 @@ window.Worlds = (() => {
<div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
${chips.map(c => _chip(c.icon, c.label, c.page, !!(c.pro && !_hasProAccess()))).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span>