Release v1.1.0

This commit is contained in:
rene 2026-04-30 07:34:06 +02:00
commit 61be87f29e
19 changed files with 864 additions and 87 deletions

View file

@ -8,25 +8,28 @@
1. APP SHELL
------------------------------------------------------------ */
#app {
display: flex;
display: flex;
flex-direction: column;
min-height: 100dvh; /* dvh: berücksichtigt mobile Browser-Chrome */
min-height: 100dvh;
}
/* Content-Bereich: füllt den Raum zwischen Header und Bottom-Nav */
#page-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */
overflow-y: auto;
overflow-x: hidden;
padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px);
-webkit-overflow-scrolling: touch;
}
/* Desktop: Sidebar-Layout */
/* Desktop: Sidebar-Layout — kein Bottom-Nav, natürliche Höhe */
@media (min-width: 768px) {
#app {
flex-direction: row;
}
#page-content {
min-height: unset;
padding-bottom: 0;
padding-left: var(--nav-sidebar-width);
}
@ -168,6 +171,10 @@
left: 0;
right: 0;
z-index: 700; /* über Leaflet-Panes (~400) */
/* GPU-Layer erzwingen → iOS Safari fixed-position Stabilität */
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
min-height: calc(var(--nav-bottom-height) + var(--safe-bottom));
padding-top: var(--space-1);
padding-bottom: calc(var(--safe-bottom) + var(--space-1));

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View file

@ -88,21 +88,12 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=382">
<link rel="stylesheet" href="/css/layout.css?v=382">
<link rel="stylesheet" href="/css/components.css?v=382">
<link rel="stylesheet" href="/css/design-system.css?v=500">
<link rel="stylesheet" href="/css/layout.css?v=500">
<link rel="stylesheet" href="/css/components.css?v=500">
</head>
<body>
<!-- Staging-Banner (nur auf staging.banyaro.app) -->
<div id="staging-banner"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:10000;
background:#7c3aed;color:#fff;font-size:0.75rem;font-weight:700;
padding:5px 16px;align-items:center;justify-content:center;gap:8px;
letter-spacing:0.04em;text-transform:uppercase">
⚗️ Staging-Umgebung — Keine Produktionsdaten
</div>
<!-- Offline-Banner -->
<div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
@ -238,9 +229,18 @@
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
border-top:1px solid var(--c-border,#e5e7eb);
font-size:var(--text-xs);color:var(--c-text-muted);
display:flex;gap:var(--space-3);padding-bottom:var(--space-2)">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-2)">
<div style="display:flex;gap:var(--space-3)">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
</div>
<div style="display:flex;justify-content:center">
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
color:var(--c-text-muted);display:inline-flex;align-items:center;gap:5px">
<svg style="width:18px;height:18px;flex-shrink:0;fill:#f59e0b;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#trophy" style="fill:#f59e0b;color:#f59e0b"></use></svg>
die 100
</span>
</div>
</div>
<!-- bot-trap: kein echter Nutzer klickt hier -->
<a href="/api/wiki/trap" aria-hidden="true" tabindex="-1"
@ -428,6 +428,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-gruender">
<div class="page-body page-container"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '490'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '503'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -69,6 +69,7 @@ const App = (() => {
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
};
// ----------------------------------------------------------
@ -863,10 +864,18 @@ const App = (() => {
// App starten
document.addEventListener('DOMContentLoaded', () => {
if (IS_STAGING) {
const b = document.getElementById('staging-banner');
if (b) b.style.display = 'flex';
document.title = '⚗️ ' + document.title;
}
App.init();
if (IS_STAGING) {
document.title = '⚗️ ' + document.title;
// Nach App.init() Styles direkt setzen — sonst überschreibt init sie
const _applyStaging = () => {
const nav = document.getElementById('bottom-nav');
if (!nav) return;
nav.style.cssText += ';background:#2d1b69!important;border-top-color:#7c3aed!important;box-shadow:0 -2px 12px rgba(124,58,237,0.4)!important';
nav.querySelectorAll('.nav-item-label').forEach(el => el.style.color = 'rgba(196,181,253,0.75)');
nav.querySelectorAll('.plus-btn, .nav-item-center button').forEach(el => el.style.background = '#7c3aed');
};
_applyStaging();
setTimeout(_applyStaging, 400); // nochmal nach vollständigem Render
}
});

View file

@ -19,6 +19,7 @@ window.Page_admin = (() => {
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'partner', label: 'Partner & Codes', icon: 'handshake' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
];
@ -88,6 +89,7 @@ window.Page_admin = (() => {
case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'partner': await _renderPartner(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
@ -1789,6 +1791,210 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderPartner(el) {
const [codes] = await Promise.all([
API.get('/api/admin/partner/codes'),
]);
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Neuen Code anlegen -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
<form id="adm-partner-create" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Code</label>
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Max. Einlösungen <span style="color:var(--c-text-muted)">(leer = unbegrenzt)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
style="width:16px;height:16px;accent-color:var(--c-primary)">
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
Gründer-Lizenz (lebenslang kostenlos)
</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
${UI.icon('plus')} Code erstellen
</button>
</form>
</div>
<!-- Aktive Codes -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
<div id="adm-partner-codes-list">
${codes.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Partner-Codes angelegt.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${codes.map(c => `
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
<td style="padding:var(--space-2) var(--space-3)">
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">
${c.grants_founder ? '✓' : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
${UI.icon('trash')} Löschen
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
</div>
<!-- User-Status vergeben -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
<form id="adm-partner-grant" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">User-ID oder Benutzername</label>
<input class="form-control" name="user_search" id="adm-grant-search"
placeholder="Name eingeben…" autocomplete="off">
<div id="adm-grant-result" style="margin-top:var(--space-2)"></div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_founder" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Gründer-Lizenz
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_partner" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Partner-Badge (Creator)
</label>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
${UI.icon('check')} Status setzen
</button>
</form>
</div>
</div>
`;
// Code erstellen
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const code = (fd.code || '').trim().toUpperCase();
if (!code) return;
await UI.asyncButton(btn, async () => {
await API.post('/api/admin/partner/codes', {
code,
label: fd.label || code,
grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0,
max_uses: fd.max_uses ? parseInt(fd.max_uses) : null,
});
UI.toast.success(`Code "${code}" erstellt.`);
await _renderPartner(el);
});
});
// Code löschen
el.querySelectorAll('.adm-del-code').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm(`Code wirklich löschen?`)) return;
const id = btn.dataset.id;
await UI.asyncButton(btn, async () => {
await API.del(`/api/admin/partner/codes/${id}`);
UI.toast.success('Code gelöscht.');
await _renderPartner(el);
});
});
});
// User suchen für Status-Vergabe
let _grantUserId = null;
const searchInput = el.querySelector('#adm-grant-search');
const grantResult = el.querySelector('#adm-grant-result');
let _searchTimeout = null;
searchInput?.addEventListener('input', () => {
clearTimeout(_searchTimeout);
_grantUserId = null;
const q = searchInput.value.trim();
if (q.length < 2) { grantResult.innerHTML = ''; return; }
_searchTimeout = setTimeout(async () => {
try {
const users = await API.get(`/api/admin/users/search?q=${encodeURIComponent(q)}`);
if (!users.length) {
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Kein User gefunden.</p>`;
return;
}
grantResult.innerHTML = users.map(u => `
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
font-size:var(--text-sm);display:flex;justify-content:space-between">
<span><strong>${u.name}</strong></span>
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
${u.is_founder ? '⭐ Gründer ' : ''}${u.is_partner ? '🤝 Partner' : ''}
</span>
</div>
`).join('');
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
div.addEventListener('click', () => {
_grantUserId = parseInt(div.dataset.id);
searchInput.value = div.dataset.name;
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt</p>`;
});
});
} catch { grantResult.innerHTML = ''; }
}, 400);
});
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
e.preventDefault();
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
const btn = e.target.querySelector('[type="submit"]');
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
await UI.asyncButton(btn, async () => {
const result = await API.post(`/api/admin/partner/users/${_grantUserId}/grant`, {
is_founder: isFounder,
is_partner: isPartner,
});
UI.toast.success(`Status für ${result.name} gesetzt.`);
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
});
});
}
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -392,6 +392,7 @@ window.Page_forum = (() => {
<div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''}
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
@ -589,6 +590,7 @@ window.Page_forum = (() => {
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">

View file

@ -0,0 +1,154 @@
/* ============================================================
BAN YARO Gründer-Seite
Öffentliches Leaderboard der 100 Gründer & Partner.
============================================================ */
window.Page_gruender = (() => {
let _container = null;
async function init(container, appState) {
_container = container;
_render();
_load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-2)">🏆</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Die 100 Gründer von Ban Yaro
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:480px;margin:0 auto">
Nur 100 Menschen weltweit können sagen: <em>Ich war von Anfang an dabei.</em>
Diese Plätze werden nie wieder vergeben.
</p>
</div>
<div id="grnd-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#grnd-content');
try {
const d = await API.get('/api/partner/founders/stats');
if (!d || typeof d.total === 'undefined') throw new Error('Ungültige Antwort vom Server.');
el.innerHTML = _renderStats(d);
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-muted);text-align:center">${e.message || 'Fehler beim Laden.'}</p>`;
}
}
function _renderStats(d) {
const pct = Math.round((d.total / d.max) * 100);
const open = d.open;
return `
<!-- Fortschrittsbalken -->
<div class="by-card" style="padding:var(--space-5);margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-2)">
<span style="font-weight:700;font-size:var(--text-lg)">${d.total} / ${d.max} Gründer</span>
<span style="font-size:var(--text-sm);color:${open > 0 ? 'var(--c-success,#16a34a)' : 'var(--c-danger,#dc2626)'}">
${open > 0 ? `${open} Plätze frei` : 'Geschlossen'}
</span>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:12px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${pct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
${open > 0 ? `
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0;text-align:center">
Bist du dabei? Frag einen unserer Partner nach ihrem Einladungscode.
</p>` : `
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0;text-align:center">
Alle 100 Gründer-Plätze sind vergeben. Diese Gruppe ist für immer geschlossen.
</p>`}
</div>
<!-- Partner-Challenge Leaderboard -->
${d.partners.length > 0 ? `
<div class="by-card" style="padding:var(--space-5);margin-bottom:var(--space-5)">
<h2 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-1)">
${UI.icon('trophy')} Partner-Challenge
</h2>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)">
Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.partners.map((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`;
const barPct = d.partners[0].uses > 0 ? Math.round((p.uses / d.partners[0].uses) * 100) : 0;
return `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}">
<div style="font-size:22px;min-width:32px;text-align:center">${medal}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div>
<div style="background:var(--c-surface-3,rgba(0,0,0,.08));border-radius:var(--radius-full);
height:6px;margin-top:var(--space-1);overflow:hidden">
<div style="background:#7c3aed;width:${barPct}%;height:100%;
border-radius:var(--radius-full)"></div>
</div>
</div>
<div style="font-weight:800;font-size:var(--text-lg);color:#7c3aed;min-width:36px;text-align:right">
${p.uses}
</div>
</div>
`;
}).join('')}
</div>
</div>` : ''}
<!-- Gründer-Galerie -->
${d.founders.length > 0 ? `
<div class="by-card" style="padding:var(--space-5)">
<h2 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">
${UI.icon('users')} Die Gründer
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-2)">
${d.founders.map(f => `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:800;color:#7c3aed;min-width:28px">#${f.founder_number}</span>
<span style="font-size:var(--text-sm);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(f.name)}
</span>
</div>
`).join('')}
${Array.from({length: d.open}, (_, i) => `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:2px dashed var(--c-border);display:flex;align-items:center;gap:var(--space-2);
opacity:.4">
<span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px">
#${d.total + i + 1}
</span>
<span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
</div>
`).join('')}
</div>
</div>` : `
<div class="by-card" style="padding:var(--space-6);text-align:center">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Gründer sei der Erste!
</p>
</div>`}
`;
}
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
return { init, refresh, onDogChange };
})();

View file

@ -124,14 +124,22 @@ window.Page_settings = (() => {
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="margin-top:var(--space-1);
color:var(--c-text-secondary)">
Kostenlos
</span>`}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium
? `<span class="badge badge-primary">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="color:var(--c-text-secondary)">Kostenlos</span>`}
${u.is_founder
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#handshake"></use></svg> Partner
</span>` : ''}
</div>
</div>
</div>
</div>
@ -299,9 +307,9 @@ window.Page_settings = (() => {
<!-- App empfehlen -->
<div class="card" style="margin-bottom:var(--space-5)" id="referral-card">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} App empfehlen</div>
<div style="font-weight:600;margin-bottom:2px">${UI.icon('arrow-square-out')} Freunde werben dauerhafter Rabatt</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Lade Freunde ein jede erfolgreiche Einladung wird in deinem Profil angezeigt.
10 Freunde 20% · 20 Freunde 30% · 50 Freunde 50% lebenslang, sobald Bezahlfunktionen aktiv sind.
</div>
</div>
<div id="referral-body" style="padding:var(--space-4)">Lade</div>
@ -1092,50 +1100,88 @@ window.Page_settings = (() => {
if (!el) return;
try {
const r = await API.auth.referral();
const TIERS = [{t:10,d:20},{t:20,d:30},{t:50,d:50}];
const currentTier = r.discount_pct;
const next = r.next_tier;
// Fortschrittsbalken-Berechnung
let barPct = 0, barLabel = '';
if (!next) {
barPct = 100;
barLabel = 'Maximaler Rabatt erreicht!';
} else {
const prevT = TIERS.find(t => t.d === currentTier)?.t || 0;
barPct = Math.round(((r.count - prevT) / (next.count - prevT)) * 100);
barLabel = `Noch ${next.count - r.count} ${next.count - r.count === 1 ? 'Person' : 'Personen'} bis ${next.discount}% Rabatt`;
}
el.innerHTML = `
<!-- Tier-Übersicht -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
${TIERS.map(({t, d}) => {
const reached = r.count >= t;
return `<div style="flex:1;padding:var(--space-2) var(--space-1);border-radius:var(--radius-md);
text-align:center;border:2px solid ${reached ? '#7c3aed' : 'var(--c-border)'};
background:${reached ? 'rgba(124,58,237,.08)' : 'var(--c-surface-2)'}">
<div style="font-size:var(--text-lg);font-weight:800;color:${reached ? '#7c3aed' : 'var(--c-text-muted)'}">
${d}%
</div>
<div style="font-size:10px;color:var(--c-text-muted)">ab ${t} Freunden</div>
${reached ? `<div style="font-size:10px;font-weight:700;color:#7c3aed">✓ Erreicht</div>` : ''}
</div>`;
}).join('')}
</div>
<!-- Zähler + Fortschritt -->
<div style="margin-bottom:var(--space-4)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:var(--space-1)">
<span style="font-size:var(--text-sm);font-weight:600">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Freund geworben' : 'Freunde geworben'}
</span>
${currentTier > 0 ? `<span style="font-size:var(--text-xs);font-weight:700;color:#7c3aed">${currentTier}% Rabatt aktiv</span>` : ''}
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-full);height:10px;overflow:hidden">
<div style="background:linear-gradient(90deg,#7c3aed,#a855f7);width:${barPct}%;height:100%;
border-radius:var(--radius-full);transition:width .5s ease"></div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">${barLabel}</div>
</div>
<!-- Link + QR -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
</div>
<!-- QR-Code -->
<div style="display:flex;justify-content:center;margin-bottom:var(--space-4)">
<div style="position:relative;width:160px;height:160px">
<div id="ref-qr" style="width:160px;height:160px;border-radius:var(--radius-md);overflow:hidden"></div>
<div style="display:flex;justify-content:center;margin-bottom:var(--space-1)">
<div style="position:relative;width:140px;height:140px">
<div id="ref-qr" style="width:140px;height:140px;border-radius:var(--radius-md);overflow:hidden"></div>
<img src="/icons/icon-180.png" alt=""
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:36px;height:36px;border-radius:8px;border:2px solid #fff">
width:32px;height:32px;border-radius:7px;border:2px solid #fff">
</div>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${UI.icon('users')} <strong>${r.count}</strong> ${r.count === 1 ? 'Person' : 'Personen'} über deinen Link registriert
</div>
${r.count > 0 ? `
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
${r.count >= 1 ? `<span class="badge badge-primary">${UI.icon('star')} Botschafter</span>` : ''}
${r.count >= 5 ? `<span class="badge badge-primary">${UI.icon('star')} Super-Botschafter</span>` : ''}
${r.count >= 10 ? `<span class="badge badge-primary">${UI.icon('star')} Top-Botschafter</span>` : ''}
</div>` : ''}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
Der Rabatt gilt für dich sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch.
</p>
`;
document.getElementById('ref-share-btn')?.addEventListener('click', async () => {
const msg = `Ich bin bei Ban Yaro — der coolsten Hunde-App! Registrier dich mit meinem Link und wir wachsen zusammen 🐾`;
if (navigator.share) {
navigator.share({ title: 'Ban Yaro — Die Hunde-App', text: 'Schau dir Ban Yaro an!', url: r.link }).catch(() => {});
navigator.share({ title: 'Ban Yaro', text: msg, url: r.link }).catch(() => {});
} else {
await navigator.clipboard.writeText(r.link);
UI.toast.success('Link kopiert!');
}
});
// QR-Code rendern (Bibliothek lazy laden)
await App.loadScript('/js/qrcode.min.js');
new QRCode(document.getElementById('ref-qr'), {
text: r.link,
width: 160,
height: 160,
colorDark: '#000000',
colorLight: '#ffffff',
text: r.link, width: 140, height: 140,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H,
});
} catch { el.innerHTML = ''; }
@ -1283,6 +1329,16 @@ window.Page_settings = (() => {
</div>
</div>
</div>
<div class="form-group" style="margin-top:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs)">
Einladungscode <span style="color:var(--c-text-muted);font-weight:400">(optional)</span>
</label>
<input class="form-control" type="text" name="partner_code" id="reg-partner-code"
placeholder="z. B. HUNDEBLOG" autocomplete="off"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em">
<div id="reg-partner-hint" style="display:none;margin-top:var(--space-1);font-size:var(--text-xs);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm)"></div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen
</button>
@ -1375,6 +1431,44 @@ window.Page_settings = (() => {
});
}
// Partner-Code live validieren
const partnerInput = document.getElementById('reg-partner-code');
const partnerHint = document.getElementById('reg-partner-hint');
let _partnerValid = false;
if (partnerInput) {
// Vorausfüllen falls via sessionStorage gesetzt
const stored = sessionStorage.getItem('by_ref_code') || '';
if (stored) partnerInput.value = stored;
let _debounce = null;
partnerInput.addEventListener('input', () => {
const code = partnerInput.value.trim().toUpperCase();
partnerInput.value = code;
clearTimeout(_debounce);
partnerHint.style.display = 'none';
_partnerValid = false;
if (code.length < 3) return;
_debounce = setTimeout(async () => {
try {
const info = await API.get(`/api/partner/codes/${encodeURIComponent(code)}/info`);
if (info.redeemable) {
partnerHint.textContent = info.grants_founder
? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!`
: `✓ Gültiger Einladungscode von "${info.label}"`;
partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
_partnerValid = true;
} else {
partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.';
partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)';
}
} catch {
partnerHint.textContent = 'Code nicht gefunden.';
partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)';
}
}, 500);
});
}
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
@ -1390,8 +1484,10 @@ window.Page_settings = (() => {
}
await UI.asyncButton(btn, async () => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined);
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
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');
@ -1401,7 +1497,10 @@ window.Page_settings = (() => {
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
const greeting = _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding();
});
});

View file

@ -1,6 +1,6 @@
{
"id": "/",
"version": "1.0.0",
"version": "1.1.0",
"name": "Ban Yaro — Die Hunde-Plattform",
"short_name": "Ban Yaro",
"description": "Alles rund um deinen Hund. Von Welpe bis Opa.",

View file

@ -3,16 +3,16 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v513';
const CACHE_VERSION = 'by-v526';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
'/css/design-system.css?v=382',
'/css/layout.css?v=382',
'/css/components.css?v=382',
'/css/design-system.css?v=500',
'/css/layout.css?v=500',
'/css/components.css?v=500',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',