Kundenbereich: App-Ansicht fuers Handy (Tab-Leiste, Konto-Sheet, installierbar)
Auf dem Handy (unter 880 px und Handys quer) sieht der Kundenbereich aus wie eine App; der Desktop bleibt pixelgleich. - Kopfzeile als App-Leiste: Logo, beim Scrollen der Seitentitel, Avatar - Navigation als Tab-Leiste unten mit Symbolen und Zaehlern; neuer Tab "Wartung" mit der Abo-Karte, Stundenkonto/Bestellung und Uebernahmegutschrift als Unterseiten mit Zurueck (auch Android-Geste) - Konto als Bottom-Sheet (Ansprechpartner, Vertraege kuendigen/widerrufen, Rechtliches, Abmelden), am Desktop als Popover unter dem Avatar - Rechnungen als Karten, Fortschritt als Zeitleiste, Chat fuellt den Bildschirm; bei offener Tastatur verschwindet die Tab-Leiste (kommt auch nach der Android-Zurueck-Geste wieder) - Installierbar: Manifest, App-Icons (WEBklar-Logo), Favicon, Hinweis "Zum Home-Bildschirm"; Service Worker nur fuer die Offline-Seite, Kundendaten werden nicht zwischengespeichert - Installierte App: Kuendigen/Widerrufen mit Link zurueck in den Kundenbereich (nur dort sichtbar, Texte unveraendert); Login-Links zu Bedingungen/Datenschutz/Impressum oeffnen wie im Dashboard ein neues Fenster - Login mit App-Icon und Abstaenden fuer Statusleiste/Home-Balken - Hinweise "in der Seitenleiste" haben auf dem Handy eine eigene Fassung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06e9a0fe8d
commit
20bf5afe28
17 files changed
+999
-36
No files matched your search
+396
-14
@@ -454,19 +454,20 @@ async function loadCustomerInvoices() {
|
||||
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
|
||||
return
|
||||
}
|
||||
// Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css)
|
||||
body.innerHTML = `
|
||||
<div class="kb-table-wrap"><table class="simple-table">
|
||||
<div class="kb-table-wrap"><table class="simple-table kb-invoices">
|
||||
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${data.invoices.map((i) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(i.number)}</td>
|
||||
<td>${fmtDateShort(i.date)}</td>
|
||||
<td>${fmtEuro(i.amount)}</td>
|
||||
<td>${fmtEuro(i.balance)}</td>
|
||||
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||||
<td style="white-space:nowrap">
|
||||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a> · ` : ''}
|
||||
<tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
|
||||
<td class="inv-no">${escapeHtml(i.number)}</td>
|
||||
<td class="inv-date">${fmtDateShort(i.date)}</td>
|
||||
<td class="inv-amount">${fmtEuro(i.amount)}</td>
|
||||
<td class="inv-open">${fmtEuro(i.balance)}</td>
|
||||
<td class="inv-status"><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||||
<td class="inv-actions" style="white-space:nowrap">
|
||||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a><span class="inv-sep" aria-hidden="true"> · </span>` : ''}
|
||||
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
@@ -831,7 +832,7 @@ function renderAboAccountView(view, data) {
|
||||
<h1>Wartungs-Abo</h1>
|
||||
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
|
||||
</header>
|
||||
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleiste, damit Ihre Website online bleibt.</div>' : ''}
|
||||
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#wartung" data-view="wartung">Wartung</a></span>, damit Ihre Website online bleibt.</div>' : ''}
|
||||
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
|
||||
<div class="kb-stats">
|
||||
<div class="kb-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
|
||||
@@ -880,8 +881,10 @@ async function loadAboCard({ afterCheckout = false } = {}) {
|
||||
card.classList.remove('hidden')
|
||||
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
|
||||
loadTakeoverCard({ abo: data })
|
||||
document.dispatchEvent(new CustomEvent('kb:abo', { detail: data }))
|
||||
} catch (err) {
|
||||
console.warn('Abo konnte nicht geladen werden:', err.message)
|
||||
document.dispatchEvent(new CustomEvent('kb:abo', { detail: null }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1139,8 +1142,9 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Navigation --- */
|
||||
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift)
|
||||
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', abo: 'abo', takeover: 'gutschrift' }
|
||||
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
|
||||
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
|
||||
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift' }
|
||||
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -1166,8 +1170,8 @@ function showView(name, { updateHash = true } = {}) {
|
||||
const hash = VIEW_HASHES[name]
|
||||
window.history.replaceState(null, '', hash ? `#${hash}` : window.location.pathname)
|
||||
}
|
||||
// Mobil liegt die Seitenleiste ueber dem Inhalt -> zum Inhalt springen
|
||||
if (window.innerWidth <= 880) document.querySelector('.kb-main')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// App-Ansicht: Titel, Zurueck-Pfeil, Tab und Scrollposition nachziehen (initAppShell)
|
||||
document.dispatchEvent(new CustomEvent('kb:view', { detail: { name } }))
|
||||
}
|
||||
|
||||
function initialsOf(name) {
|
||||
@@ -1183,6 +1187,8 @@ async function initDashboardPage() {
|
||||
const loadError = document.getElementById('load-error')
|
||||
const logoutBtn = document.getElementById('logout-btn')
|
||||
|
||||
initAppShell()
|
||||
|
||||
logoutBtn?.addEventListener('click', async () => {
|
||||
await api('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/login.html'
|
||||
@@ -1277,5 +1283,381 @@ async function initDashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- App-Ansicht (Handy) --- */
|
||||
// Auf dem Handy wird der Kundenbereich zur App: die Navigation ist die Tab-Leiste unten, die
|
||||
// Kopfzeile zeigt beim Scrollen den Seitentitel, Stundenkonto/Bestellung und Gutschrift sind
|
||||
// Unterseiten der Wartung (mit Zurueck), das Konto ist ein Bottom-Sheet. Styles: kundenbereich.css.
|
||||
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
|
||||
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert
|
||||
const APP_PARENT = { abo: 'wartung', takeover: 'wartung' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
function isAppLayout() {
|
||||
return appLayout.matches
|
||||
}
|
||||
|
||||
function onMediaChange(mq, fn) {
|
||||
if (mq.addEventListener) mq.addEventListener('change', fn)
|
||||
else mq.addListener(fn)
|
||||
}
|
||||
|
||||
function isStandaloneApp() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true
|
||||
}
|
||||
|
||||
function isIosDevice() {
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
}
|
||||
|
||||
// Karten der Seitenleiste: App-Ansicht -> Ansprechpartner ins Konto-Sheet, Abo-Karte in den Tab "Wartung"
|
||||
function placeSideCards() {
|
||||
for (const { el, slot, anchor } of appShell.sideCards) {
|
||||
if (isAppLayout()) {
|
||||
if (el.parentNode !== slot) slot.append(el)
|
||||
} else if (anchor.nextSibling !== el) {
|
||||
anchor.after(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentViewTitle() {
|
||||
return document.querySelector(`#view-${appShell.view} .kb-head h1`)
|
||||
}
|
||||
|
||||
// Kopfzeile: Hintergrund nach dem Scrollen, Seitentitel sobald die grosse Ueberschrift darunter verschwindet
|
||||
function updateBarState() {
|
||||
const header = document.querySelector('.kb-header')
|
||||
const h1 = currentViewTitle()
|
||||
const barBottom = header ? header.getBoundingClientRect().bottom : 0
|
||||
document.body.classList.toggle('kb-scrolled', window.scrollY > 4)
|
||||
document.body.classList.toggle('kb-titled', Boolean(h1 && h1.getBoundingClientRect().bottom < barBottom + 4))
|
||||
}
|
||||
|
||||
function updateAppBar() {
|
||||
const sub = isAppLayout() && Boolean(APP_PARENT[appShell.view])
|
||||
document.getElementById('kb-back')?.classList.toggle('hidden', !sub)
|
||||
document.body.classList.toggle('kb-subpage', sub)
|
||||
const title = document.getElementById('kb-app-title')
|
||||
if (title) title.textContent = currentViewTitle()?.textContent.trim() || ''
|
||||
updateBarState()
|
||||
}
|
||||
|
||||
function onAppViewShown(name) {
|
||||
// Den Tab "Wartung" gibt es nur in der App-Ansicht; am Desktop direkt das Wartungs-Abo zeigen
|
||||
if (name === 'wartung' && !isAppLayout()) {
|
||||
showView('abo', { updateHash: !appShell.fromHistory })
|
||||
return
|
||||
}
|
||||
const prev = appShell.view
|
||||
const parent = APP_PARENT[name]
|
||||
const nav = name === prev ? 'same' : parent === prev ? 'push' : APP_PARENT[prev] === name ? 'back' : 'tab'
|
||||
appShell.view = name
|
||||
document.body.dataset.kbView = name
|
||||
document.body.dataset.kbNav = nav
|
||||
|
||||
// Unterseiten markieren ihren Tab
|
||||
document.querySelectorAll('.kb-nav-item').forEach((b) => {
|
||||
const on = b.dataset.view === (parent || name)
|
||||
b.classList.toggle('active', on)
|
||||
if (on) b.setAttribute('aria-current', 'page')
|
||||
else b.removeAttribute('aria-current')
|
||||
})
|
||||
|
||||
if (isAppLayout()) {
|
||||
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zur Wartung
|
||||
if (nav === 'push' && !appShell.fromHistory) {
|
||||
window.history.replaceState(null, '', `#${VIEW_HASHES[parent]}`)
|
||||
window.history.pushState({ kbSub: name }, '', `#${VIEW_HASHES[name]}`)
|
||||
}
|
||||
// Tabs merken sich ihre Scrollposition, Unterseiten beginnen oben, erneutes Tippen scrollt nach oben
|
||||
if (nav === 'same') window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
else window.scrollTo(0, nav === 'push' ? 0 : appShell.scroll[name] || 0)
|
||||
}
|
||||
updateAppBar()
|
||||
}
|
||||
|
||||
/* Wartungs-Tab: Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
|
||||
function updateWartungBadge() {
|
||||
const card = document.getElementById('abo-card')
|
||||
const badge = document.getElementById('kb-tab-wartung-badge')
|
||||
if (!card || !badge) return
|
||||
const paymentProblem = Boolean(card.querySelector('.kb-side-alert'))
|
||||
const questions = Number(card.querySelector('#takeover-badge:not(.hidden)')?.textContent || 0)
|
||||
badge.textContent = paymentProblem ? '!' : questions || ''
|
||||
badge.title = paymentProblem ? 'Zahlung fehlgeschlagen' : questions ? 'Rückfrage offen' : ''
|
||||
badge.classList.toggle('kb-badge-alert', paymentProblem)
|
||||
badge.classList.toggle('hidden', !paymentProblem && !questions)
|
||||
}
|
||||
|
||||
/* "Als App installieren": Chrome/Android per Installationsdialog, iPhone über Teilen -> Home-Bildschirm */
|
||||
function renderInstallOffers() {
|
||||
let dismissed = false
|
||||
try {
|
||||
dismissed = localStorage.getItem('kb-install-dismissed') === '1'
|
||||
} catch {
|
||||
/* ohne Speicher einfach wieder anzeigen */
|
||||
}
|
||||
const canPrompt = Boolean(appShell.installPrompt)
|
||||
const ios = isIosDevice()
|
||||
const offer = isAppLayout() && !isStandaloneApp() && (canPrompt || ios)
|
||||
const shareIcon = '<svg class="kb-inline-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1"/></svg>'
|
||||
document.querySelectorAll('[data-install]').forEach((box) => {
|
||||
const dismissible = box.hasAttribute('data-install-dismissible')
|
||||
const show = offer && !(dismissible && dismissed)
|
||||
box.classList.toggle('hidden', !show)
|
||||
if (!show) return
|
||||
box.innerHTML = `
|
||||
<img class="kb-install-icon" src="/img/app-icon-192.png" alt="" width="44" height="44">
|
||||
<div class="kb-install-text">
|
||||
<strong>WEBklar als App</strong>
|
||||
<span>${canPrompt
|
||||
? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
|
||||
: `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}</span>
|
||||
</div>
|
||||
${canPrompt ? '<button type="button" class="kb-install-go" data-install-go>Installieren</button>' : ''}
|
||||
${dismissible ? '<button type="button" class="kb-install-x" data-install-dismiss aria-label="Hinweis ausblenden"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button>' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
/* Konto: Bottom-Sheet (Handy) bzw. Popover (Desktop) mit Ansprechpartner, Rechtlichem und Abmelden */
|
||||
function initAccountSheet() {
|
||||
const sheet = document.getElementById('kb-sheet')
|
||||
const avatar = document.getElementById('customer-avatar')
|
||||
if (!sheet || !avatar) return
|
||||
if (typeof sheet.showModal !== 'function') {
|
||||
// Sehr alte Browser ohne <dialog>: Abmelden bleibt in der Kopfzeile
|
||||
document.body.classList.add('kb-no-sheet')
|
||||
avatar.disabled = true
|
||||
return
|
||||
}
|
||||
const panel = sheet.querySelector('.kb-sheet-panel')
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
// Zaehler gegen verspaetetes Schliessen, wenn das Sheet gleich wieder geoeffnet wurde
|
||||
let closeToken = 0
|
||||
|
||||
const open = () => {
|
||||
if (sheet.open) return
|
||||
closeToken += 1
|
||||
document.getElementById('kb-sheet-name').textContent = document.getElementById('customer-name')?.textContent || 'Mein Konto'
|
||||
document.getElementById('kb-sheet-meta').textContent = document.getElementById('customer-meta')?.textContent || ''
|
||||
document.getElementById('kb-sheet-avatar').textContent = avatar.textContent
|
||||
renderInstallOffers()
|
||||
sheet.classList.remove('is-closing')
|
||||
panel.style.transform = ''
|
||||
sheet.showModal()
|
||||
document.documentElement.classList.add('kb-sheet-open')
|
||||
}
|
||||
const finish = () => {
|
||||
sheet.classList.remove('is-closing')
|
||||
panel.style.transform = ''
|
||||
if (sheet.open) sheet.close()
|
||||
}
|
||||
const close = () => {
|
||||
if (!sheet.open || sheet.classList.contains('is-closing')) return
|
||||
if (reducedMotion.matches) return finish()
|
||||
const token = ++closeToken
|
||||
const done = () => {
|
||||
if (token === closeToken) finish()
|
||||
}
|
||||
sheet.classList.add('is-closing')
|
||||
panel.style.transform = ''
|
||||
panel.addEventListener('transitionend', done, { once: true })
|
||||
setTimeout(done, 400)
|
||||
}
|
||||
|
||||
avatar.addEventListener('click', open)
|
||||
// Escape bzw. Android-Zurück: mit Animation schließen
|
||||
sheet.addEventListener('cancel', (ev) => {
|
||||
ev.preventDefault()
|
||||
close()
|
||||
})
|
||||
sheet.addEventListener('close', () => {
|
||||
sheet.classList.remove('is-closing')
|
||||
document.documentElement.classList.remove('kb-sheet-open')
|
||||
avatar.focus({ preventScroll: true })
|
||||
})
|
||||
// Klick auf den Hintergrund, das X oder eine Ansicht (z. B. "Nachricht schreiben") schließt
|
||||
sheet.addEventListener('click', (ev) => {
|
||||
if (ev.target === sheet || ev.target.closest('[data-sheet-close], [data-view]')) close()
|
||||
})
|
||||
document.getElementById('kb-sheet-logout')?.addEventListener('click', () => document.getElementById('logout-btn')?.click())
|
||||
|
||||
// Nach unten wischen schließt das Sheet (Griff und Kopf des Sheets)
|
||||
let drag = null
|
||||
panel.addEventListener('pointerdown', (ev) => {
|
||||
if (!isAppLayout() || !ev.target.closest('.kb-sheet-grip, .kb-sheet-me') || ev.target.closest('button')) return
|
||||
drag = { y: ev.clientY, t: ev.timeStamp, dy: 0 }
|
||||
panel.setPointerCapture(ev.pointerId)
|
||||
panel.classList.add('is-dragging')
|
||||
})
|
||||
panel.addEventListener('pointermove', (ev) => {
|
||||
if (!drag) return
|
||||
drag.dy = Math.max(0, ev.clientY - drag.y)
|
||||
panel.style.transform = `translateY(${drag.dy}px)`
|
||||
})
|
||||
const endDrag = (ev) => {
|
||||
if (!drag) return
|
||||
const { dy, t } = drag
|
||||
drag = null
|
||||
panel.classList.remove('is-dragging')
|
||||
if (dy > 90 || dy / Math.max(1, ev.timeStamp - t) > 0.6) close()
|
||||
else panel.style.transform = ''
|
||||
}
|
||||
panel.addEventListener('pointerup', endDrag)
|
||||
panel.addEventListener('pointercancel', endDrag)
|
||||
}
|
||||
|
||||
function initAppShell() {
|
||||
// Karten der Seitenleiste mit Anker merken, damit sie am Desktop an ihren Platz zurueckkehren
|
||||
for (const [selector, slotId] of [['.kb-side .kb-contact', 'kb-contact-slot'], ['#abo-card', 'kb-abo-slot']]) {
|
||||
const el = document.querySelector(selector)
|
||||
const slot = document.getElementById(slotId)
|
||||
if (!el || !slot) continue
|
||||
const anchor = document.createComment(`Platz: ${selector}`)
|
||||
el.before(anchor)
|
||||
appShell.sideCards.push({ el, slot, anchor })
|
||||
}
|
||||
placeSideCards()
|
||||
document.body.dataset.kbView = appShell.view
|
||||
|
||||
document.addEventListener('kb:view', (ev) => onAppViewShown(ev.detail.name))
|
||||
document.addEventListener('kb:abo', (ev) => {
|
||||
document.getElementById('kb-tab-wartung')?.classList.toggle('hidden', !ev.detail)
|
||||
const slot = document.getElementById('kb-abo-slot')
|
||||
slot?.querySelector(':scope > .kb-empty')?.remove()
|
||||
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Die Wartung konnte gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
|
||||
updateWartungBadge()
|
||||
updateAppBar()
|
||||
})
|
||||
const aboCard = document.getElementById('abo-card')
|
||||
if (aboCard) new MutationObserver(updateWartungBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zur Wartung
|
||||
document.getElementById('kb-back')?.addEventListener('click', () => {
|
||||
if (window.history.state?.kbSub) window.history.back()
|
||||
else showView(APP_PARENT[appShell.view] || 'projects')
|
||||
})
|
||||
window.addEventListener('popstate', () => {
|
||||
const name = viewFromHash() || 'projects'
|
||||
if (name === appShell.view) return
|
||||
appShell.fromHistory = true
|
||||
try {
|
||||
showView(name, { updateHash: false })
|
||||
} finally {
|
||||
appShell.fromHistory = false
|
||||
}
|
||||
})
|
||||
|
||||
// Scrollposition je Ansicht und Zustand der Kopfzeile (einmal pro Frame)
|
||||
let scrollQueued = false
|
||||
window.addEventListener('scroll', () => {
|
||||
if (scrollQueued) return
|
||||
scrollQueued = true
|
||||
requestAnimationFrame(() => {
|
||||
scrollQueued = false
|
||||
appShell.scroll[appShell.view] = window.scrollY
|
||||
updateBarState()
|
||||
})
|
||||
}, { passive: true })
|
||||
|
||||
// Tastatur offen (Touch): Tab-Leiste ausblenden, damit Eingabe und Chat Platz haben
|
||||
document.addEventListener('focusin', (ev) => {
|
||||
if (coarsePointer.matches && ev.target.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
|
||||
})
|
||||
document.addEventListener('focusout', () => {
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.remove('kb-typing')
|
||||
}, 80)
|
||||
})
|
||||
|
||||
// Sichtbare Hoehe (ohne Tastatur) fuer den Chat; neueste Nachricht bleibt sichtbar.
|
||||
// Schliesst die Tastatur, ohne dass das Feld den Fokus verliert (Android-Zurueck-Geste),
|
||||
// kommt die Tab-Leiste zurueck; tippt man erneut hinein, verschwindet sie wieder.
|
||||
const vv = window.visualViewport
|
||||
if (vv) {
|
||||
let fullHeight = vv.height
|
||||
let width = vv.width
|
||||
let keyboardOpen = false
|
||||
const syncHeight = () => {
|
||||
document.documentElement.style.setProperty('--kb-vvh', `${Math.round(vv.height)}px`)
|
||||
if (Math.abs(vv.width - width) > 1) {
|
||||
width = vv.width
|
||||
fullHeight = vv.height
|
||||
}
|
||||
fullHeight = Math.max(fullHeight, vv.height)
|
||||
const open = vv.height < fullHeight - 150
|
||||
if (open !== keyboardOpen) {
|
||||
keyboardOpen = open
|
||||
if (!open) document.body.classList.remove('kb-typing')
|
||||
else if (coarsePointer.matches && document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
|
||||
}
|
||||
// Chat mit Tastatur: iOS schiebt die Seite hoch; App-Leiste, Verlauf und Eingabe passen genau hinein
|
||||
if (keyboardOpen && appShell.view === 'chat' && isAppLayout()) window.scrollTo(0, 0)
|
||||
}
|
||||
vv.addEventListener('resize', syncHeight)
|
||||
syncHeight()
|
||||
}
|
||||
const messages = document.getElementById('chat-messages')
|
||||
if (messages && typeof ResizeObserver !== 'undefined') {
|
||||
let atBottom = true
|
||||
messages.addEventListener('scroll', () => {
|
||||
atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 48
|
||||
}, { passive: true })
|
||||
new ResizeObserver(() => {
|
||||
if (atBottom) messages.scrollTop = messages.scrollHeight
|
||||
}).observe(messages)
|
||||
}
|
||||
|
||||
// Installations-Hinweis
|
||||
window.addEventListener('beforeinstallprompt', (ev) => {
|
||||
ev.preventDefault()
|
||||
appShell.installPrompt = ev
|
||||
renderInstallOffers()
|
||||
})
|
||||
window.addEventListener('appinstalled', () => {
|
||||
appShell.installPrompt = null
|
||||
renderInstallOffers()
|
||||
})
|
||||
document.addEventListener('click', async (ev) => {
|
||||
if (ev.target.closest('[data-install-dismiss]')) {
|
||||
try {
|
||||
localStorage.setItem('kb-install-dismissed', '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
renderInstallOffers()
|
||||
} else if (ev.target.closest('[data-install-go]') && appShell.installPrompt) {
|
||||
const prompt = appShell.installPrompt
|
||||
appShell.installPrompt = null
|
||||
await prompt.prompt()
|
||||
renderInstallOffers()
|
||||
}
|
||||
})
|
||||
|
||||
onMediaChange(appLayout, () => {
|
||||
placeSideCards()
|
||||
if (!isAppLayout() && appShell.view === 'wartung') showView('abo')
|
||||
updateAppBar()
|
||||
renderInstallOffers()
|
||||
})
|
||||
|
||||
initAccountSheet()
|
||||
renderInstallOffers()
|
||||
updateAppBar()
|
||||
|
||||
// Nach der Bestellung bei Stripe: Bestaetigung steht auf der Abo-Karte im Tab "Wartung"
|
||||
if (isAppLayout() && getQueryParam('abo') === 'success') showView('wartung')
|
||||
}
|
||||
|
||||
// App-Installation (Homescreen) und Offline-Seite; zwischengespeichert wird nur die Offline-Seite (sw.js)
|
||||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('Service Worker:', err.message))
|
||||
})
|
||||
}
|
||||
|
||||
window.initLoginPage = initLoginPage
|
||||
window.initDashboardPage = initDashboardPage
|
||||
Reference in new issue
Block a user