Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.
- ${sub.paymentProblem ? 'Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleiste, damit Ihre Website online bleibt.
' : ''}
+ ${sub.paymentProblem ? '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.
` : ''}
${fmtHours(l.balanceMinutes)} verfügbare Zeit
@@ -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 = '
'
+ 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 = `
+
+
+ WEBklar als App
+ ${canPrompt
+ ? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
+ : `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}
+
+ ${canPrompt ? '
Installieren ' : ''}
+ ${dismissible ? '
' : ''}`
+ })
+}
+
+/* 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
: 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', 'Die Wartung konnte gerade nicht geladen werden. Bitte später noch einmal versuchen.
')
+ 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
diff --git a/public/dashboard.html b/public/dashboard.html
index 83b2a3f..1322569 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -2,8 +2,19 @@
-
+
Kundenbereich – WEBklar
+
+
+
+
+
+
+
+
+
+
+
@@ -16,26 +27,75 @@
+
+
+
+
+
-
+
Kundenbereich
- Projekte
- Fortschritt
- Rechnungen
- Chat
+
+
+ Projekte
+
+
+ Fortschritt
+
+
+ Rechnungen
+
+
+ Chat
+
+
+
+ Wartung
@@ -97,6 +159,16 @@
+
+
+
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..3c01d69
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..ef2669e
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/public/img/app-icon-192.png b/public/img/app-icon-192.png
new file mode 100644
index 0000000..63ba5c4
Binary files /dev/null and b/public/img/app-icon-192.png differ
diff --git a/public/img/app-icon-512.png b/public/img/app-icon-512.png
new file mode 100644
index 0000000..2e3a8b6
Binary files /dev/null and b/public/img/app-icon-512.png differ
diff --git a/public/img/app-icon-maskable-512.png b/public/img/app-icon-maskable-512.png
new file mode 100644
index 0000000..32f9bea
Binary files /dev/null and b/public/img/app-icon-maskable-512.png differ
diff --git a/public/img/apple-touch-icon.png b/public/img/apple-touch-icon.png
new file mode 100644
index 0000000..c46aef7
Binary files /dev/null and b/public/img/apple-touch-icon.png differ
diff --git a/public/kuendigen.html b/public/kuendigen.html
index a9211b3..33128ad 100644
--- a/public/kuendigen.html
+++ b/public/kuendigen.html
@@ -2,12 +2,14 @@
-
+
Verträge hier kündigen – WEBklar
+
+ ‹ Zurück zum Kundenbereich
WEBKLAR
Verträge hier kündigen
diff --git a/public/kundenbereich.css b/public/kundenbereich.css
index 3bd334a..734ca1a 100644
--- a/public/kundenbereich.css
+++ b/public/kundenbereich.css
@@ -335,17 +335,340 @@
.kb-legal a { color: var(--kb-muted); }
.kb-legal a.legal-strong { color: #fff; font-weight: 700; text-decoration: underline; }
-/* ----------------------------------------------------------------- Mobil --- */
-@media (max-width: 880px) {
- .kb-shell { grid-template-columns: 1fr; gap: 22px; padding-top: 22px; }
- .kb-side > .kb-label { display: none; }
- .kb-nav { grid-template-columns: 1fr 1fr; gap: 6px; }
- .kb-account-text { display: none; }
+/* ------------------------------------------ Konto (Avatar-Menue, alle Groessen) --- */
+/* Desktop: Popover unter dem Avatar. App-Ansicht: Bottom-Sheet (siehe unten). */
+button.kb-avatar {
+ box-sizing: content-box; padding: 0; color: #fff; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
+ transition: box-shadow 0.15s;
}
+/* Wrapper fuer Logo und App-Titel: am Desktop ohne eigene Box (Kopfzeile wie bisher) */
+.kb-bar-title { display: contents; }
+button.kb-avatar:hover { box-shadow: 0 0 0 3px rgba(157, 61, 254, 0.35); }
+button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+button.kb-avatar:disabled { cursor: default; box-shadow: none; }
+
+.kb-sheet {
+ inset: 0; margin: 72px clamp(16px, 3vw, 36px) auto auto; padding: 0; border: none; background: transparent;
+ width: 340px; max-width: calc(100vw - 32px); max-height: none; overflow: visible; color: #fff;
+}
+.kb-sheet::backdrop { background: rgba(0, 0, 0, 0.35); transition: opacity 0.2s; }
+.kb-sheet.is-closing::backdrop { opacity: 0; }
+.kb-sheet-panel {
+ box-sizing: border-box; max-height: calc(100vh - 96px); overflow-y: auto; padding: 16px;
+ border-radius: 18px; background: #111114; border: 1px solid var(--kb-border-strong);
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
+ transition: opacity 0.16s, transform 0.16s;
+}
+.kb-sheet[open] .kb-sheet-panel { animation: kb-pop-in 0.18s ease-out; }
+.kb-sheet.is-closing .kb-sheet-panel { opacity: 0; transform: translateY(-6px) scale(0.98); }
+.kb-sheet-grip { display: none; }
+.kb-sheet-me { display: flex; align-items: center; gap: 12px; padding: 2px 0 14px; }
+.kb-sheet-me .kb-avatar { width: 46px; height: 46px; font-size: 16px; }
+.kb-sheet-me-text { flex: 1; min-width: 0; line-height: 1.35; }
+.kb-sheet-me-text strong { display: block; font-size: 16px; }
+.kb-sheet-me-text span { display: block; font-size: 12.5px; color: var(--kb-muted); overflow-wrap: anywhere; }
+.kb-sheet-x {
+ flex: none; align-self: flex-start; width: 34px; height: 34px; border-radius: 50%; border: none;
+ background: rgba(255, 255, 255, 0.08); color: #fff; display: grid; place-items: center; cursor: pointer;
+}
+.kb-sheet-x svg, .kb-install-x svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
+#kb-contact-slot .kb-contact { margin-bottom: 12px; }
+.kb-sheet-links {
+ display: flex; flex-direction: column; margin: 12px 0; border-radius: 14px; overflow: hidden;
+ border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02);
+}
+.kb-sheet-links a {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 14px;
+ color: var(--kb-muted); font-size: 14.5px; text-decoration: none;
+}
+.kb-sheet-links a + a { border-top: 1px solid var(--kb-border); }
+.kb-sheet-links a::after { content: '›'; font-size: 20px; line-height: 1; color: var(--kb-faint); }
+.kb-sheet-links a[target='_blank']::after { content: '↗'; font-size: 15px; }
+.kb-sheet-links a.legal-strong { color: #fff; font-weight: 700; }
+.kb-sheet-links a:active { background: rgba(255, 255, 255, 0.05); }
+.kb-sheet-logout {
+ width: 100%; padding: 13px; border-radius: 14px; border: 1px solid rgba(248, 113, 113, 0.45);
+ background: rgba(248, 113, 113, 0.08); color: #fca5a5; font: inherit; font-size: 15px; font-weight: 700; cursor: pointer;
+}
+.kb-sheet-logout:hover { background: rgba(248, 113, 113, 0.14); }
+
+/* "WEBklar als App": Hinweis im Tab Projekte (ausblendbar) und im Konto-Sheet */
+.kb-install {
+ display: flex; align-items: center; gap: 12px; margin: 12px 0; padding: 12px 12px 12px 14px; border-radius: 16px;
+ background: linear-gradient(135deg, rgba(157, 61, 254, 0.22), rgba(157, 61, 254, 0.06)); border: 1px solid var(--kb-purple-line);
+}
+.kb-install-card { margin: 0 0 16px; }
+.kb-install-icon { flex: none; width: 44px; height: 44px; border-radius: 11px; }
+.kb-install-text { flex: 1; min-width: 0; line-height: 1.4; }
+.kb-install-text strong { display: block; font-size: 14.5px; }
+.kb-install-text span { display: block; font-size: 12.5px; color: rgba(255, 255, 255, 0.72); }
+.kb-inline-ico { width: 16px; height: 16px; vertical-align: -3px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.kb-install-go {
+ flex: none; padding: 9px 14px; border-radius: 999px; border: none; background: var(--kb-purple); color: #fff;
+ font: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
+}
+.kb-install-x {
+ flex: none; align-self: flex-start; width: 30px; height: 30px; margin: -6px -6px 0 -4px; border: none; background: none;
+ color: var(--kb-faint); display: grid; place-items: center; cursor: pointer;
+}
+
+/* Elemente, die es nur in der App-Ansicht gibt */
+.kb-back, .kb-logo-mark, .kb-app-title, .kb-tab-ico, .kb-nav-item.kb-tab-wartung, .kb-app-only, .kb-install { display: none; }
+
+@keyframes kb-pop-in { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }
+@keyframes kb-sheet-up { from { transform: translateY(100%); } }
+@keyframes kb-fade-in { from { opacity: 0; } }
+@keyframes kb-view-in { from { opacity: 0; transform: translateY(8px); } }
+@keyframes kb-push-in { from { opacity: 0; transform: translateX(32px); } }
+@keyframes kb-back-in { from { opacity: 0; transform: translateX(-32px); } }
+@keyframes kb-shimmer { to { background-position: -200% 0, 0 0; } }
+
@media (max-width: 640px) {
.kb-facts { grid-template-columns: 1fr; }
.kb-facts > div + div { border-left: none; border-top: 1px solid var(--kb-border); }
.abo-features { grid-template-columns: 1fr; }
- .kb-head h1 { font-size: 26px; }
.kb-project-body, .kb-panel { padding: 16px; }
}
+
+/* ----------------------------------------------------- App-Ansicht (Handy) --- */
+/* Unter 880px (und Handys quer): Kopfzeile als App-Leiste (Titel erscheint beim Scrollen),
+ Navigation als Tab-Leiste unten, Karten der Seitenleiste im Konto-Sheet bzw. Tab "Wartung".
+ Dieselbe Bedingung steht in app.js (appLayout). */
+@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
+ .kb-page {
+ --kb-appbar-h: calc(56px + env(safe-area-inset-top));
+ --kb-tabbar-h: calc(62px + env(safe-area-inset-bottom));
+ -webkit-text-size-adjust: 100%;
+ -webkit-tap-highlight-color: transparent;
+ }
+ .kb-page.kb-typing { --kb-tabbar-h: 0px; }
+ html.kb-sheet-open { overflow: hidden; }
+ .kb-desk-only { display: none; }
+ .kb-app-only { display: inline; }
+
+ /* App-Leiste oben */
+ .kb-header {
+ flex-wrap: nowrap; gap: 8px; box-sizing: border-box; height: var(--kb-appbar-h);
+ padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
+ background: transparent; border-bottom-color: transparent; -webkit-backdrop-filter: none; backdrop-filter: none;
+ transition: background-color 0.2s, border-color 0.2s;
+ -webkit-user-select: none; user-select: none;
+ }
+ .kb-scrolled .kb-header {
+ background: rgba(0, 0, 0, 0.8); border-bottom-color: var(--kb-border);
+ -webkit-backdrop-filter: saturate(160%) blur(18px); backdrop-filter: saturate(160%) blur(18px);
+ }
+ .kb-back {
+ display: inline-flex; flex: none; align-items: center; gap: 1px; margin-left: -10px; padding: 8px 6px 8px 2px;
+ border: none; background: none; color: #d4b4ff; font: inherit; font-size: 16px; cursor: pointer;
+ }
+ .kb-back svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
+ .kb-bar-title { position: relative; flex: 1; min-width: 0; height: 40px; display: flex; align-items: center; }
+ .kb-logo {
+ display: inline-flex; align-items: center; gap: 10px; font-size: 14px; letter-spacing: 0.22em;
+ transition: opacity 0.2s, transform 0.2s, visibility 0.2s;
+ }
+ .kb-logo-mark { display: block; flex: none; width: 28px; height: 28px; border-radius: 8px; }
+ .kb-app-title {
+ display: block; position: absolute; top: 50%; left: 0; right: 0; transform: translateY(calc(-50% + 8px)); opacity: 0;
+ font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
+ transition: opacity 0.2s, transform 0.2s;
+ }
+ .kb-titled .kb-app-title { opacity: 1; transform: translateY(-50%); }
+ .kb-titled .kb-logo, .kb-subpage .kb-logo { opacity: 0; visibility: hidden; transform: translateY(-8px); }
+ .kb-account { flex: none; gap: 0; }
+ .kb-account-text, .kb-logout { display: none; }
+ .kb-no-sheet .kb-logout { display: inline-block; margin-left: 10px; }
+ .kb-header .kb-avatar { width: 36px; height: 36px; font-size: 13.5px; }
+
+ /* Inhalt: eine Spalte, Platz fuer die Tab-Leiste; auf Tablets mittig begrenzt */
+ .kb-shell.dashboard-content-reveal {
+ display: block; max-width: 720px; overflow-x: clip;
+ padding: 6px max(16px, env(safe-area-inset-right)) calc(var(--kb-tabbar-h) + 28px) max(16px, env(safe-area-inset-left));
+ animation: kb-fade-in 0.4s ease-out both;
+ }
+ .kb-side { display: block; }
+ .kb-side > .kb-label, .kb-side > .kb-card, .kb-side > .kb-abo { display: none; }
+
+ /* Tab-Leiste unten (die Navigation der Seitenleiste) */
+ .kb-nav {
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; display: flex; justify-content: center; gap: 0;
+ padding: 6px max(6px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(6px, env(safe-area-inset-left));
+ background: rgba(12, 12, 14, 0.94); border-top: 1px solid var(--kb-border);
+ -webkit-backdrop-filter: saturate(160%) blur(20px); backdrop-filter: saturate(160%) blur(20px);
+ -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
+ transition: transform 0.22s ease;
+ }
+ .kb-typing .kb-nav { transform: translateY(110%); }
+ .kb-nav-item {
+ position: relative; flex: 1 1 0; min-width: 0; max-width: 120px; flex-direction: column; justify-content: center; gap: 3px;
+ padding: 4px 0 2px; border: none; border-radius: 12px; background: none; color: rgba(255, 255, 255, 0.55);
+ font-size: 10.5px; font-weight: 600; letter-spacing: 0; text-align: center; touch-action: manipulation;
+ }
+ .kb-nav-item.kb-tab-wartung { display: flex; }
+ .kb-nav-item::before {
+ position: absolute; top: 3px; left: 50%; width: 56px; height: 30px; margin-left: -28px; border-radius: 999px;
+ background: transparent; box-shadow: none; transition: background-color 0.2s;
+ }
+ .kb-nav-item:hover { background: none; color: rgba(255, 255, 255, 0.55); }
+ .kb-nav-item.active, .kb-nav-item.active:hover { background: none; border-color: transparent; color: #fff; font-weight: 700; }
+ .kb-nav-item.active::before { background: rgba(157, 61, 254, 0.3); box-shadow: none; }
+ .kb-tab-ico {
+ display: block; position: relative; width: 24px; height: 24px; margin-top: 3px; fill: none; stroke: currentColor;
+ stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform 0.12s;
+ }
+ .kb-nav-item:active .kb-tab-ico { transform: scale(0.88); }
+ .kb-tab-label { position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .kb-nav-item .kb-count { display: none; }
+ .kb-nav-item .kb-badge {
+ position: absolute; top: 0; left: calc(50% + 6px); margin: 0; min-width: 19px; height: 19px; padding: 0 5px;
+ box-sizing: border-box; font-size: 10.5px; line-height: 1; border: 2px solid #0c0c0e;
+ }
+ .kb-nav-item .kb-badge-red, .kb-nav-item .kb-badge-alert { background: #e5484d; color: #fff; }
+
+ /* Seiten: grosse Ueberschrift wie in iOS, weiche Uebergaenge */
+ .kb-head { margin: 6px 0 18px; padding-bottom: 0; border-bottom: none; }
+ .kb-head h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.15; }
+ .kb-head p { margin-top: 4px; font-size: 14.5px; }
+ .kb-view.active { animation: kb-view-in 0.24s ease-out; }
+ body[data-kb-nav='push'] .kb-view.active { animation-name: kb-push-in; }
+ body[data-kb-nav='back'] .kb-view.active { animation-name: kb-back-in; }
+ .kb-panel { padding: 16px; border-radius: 18px; }
+ .viewas-banner { margin-bottom: 12px; }
+
+ /* Projekte */
+ #loading:not(.hidden) {
+ height: 300px; margin: 0; border-radius: 20px; border: 1px solid var(--kb-border); color: transparent; overflow: hidden;
+ background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.06) 50%, transparent 70%) 0 0 / 200% 100%, var(--kb-surface);
+ animation: kb-shimmer 1.4s linear infinite;
+ }
+ .kb-projects { gap: 16px; }
+ .kb-project { border-radius: 20px; }
+ .kb-project-body { padding: 16px 16px 18px; }
+ .kb-project-top { margin-bottom: 12px; }
+ .kb-project-top h2 { font-size: 19px; }
+ .kb-open { right: 10px; bottom: 10px; padding: 7px 12px; font-size: 12.5px; }
+ .kb-chip { padding: 7px 12px; font-size: 13px; }
+ .kb-project [data-details-btn] { width: 100%; justify-content: center; padding: 12px 16px; font-size: 14px; }
+ .kb-details .simple-table { font-size: 13px; }
+
+ /* Fortschritt als Zeitleiste */
+ #activity-body .activity-item { position: relative; padding: 0 0 18px 22px; border-bottom: none; }
+ #activity-body .activity-item::before {
+ content: ''; position: absolute; left: 0; top: 7px; width: 9px; height: 9px; border-radius: 50%;
+ background: var(--kb-purple); box-shadow: 0 0 0 3px rgba(157, 61, 254, 0.22);
+ }
+ #activity-body .activity-item::after { content: ''; position: absolute; left: 4px; top: 22px; bottom: 2px; width: 1px; background: var(--kb-border-strong); }
+ #activity-body .activity-item:last-child { padding-bottom: 0; }
+ #activity-body .activity-item:last-child::after { display: none; }
+
+ /* Rechnungen: jede Zeile als Karte */
+ #invoices-body:has(.kb-invoices) { padding: 0; background: none; border: none; }
+ .kb-invoices, .kb-invoices tbody { display: block; }
+ .kb-invoices thead { display: none; }
+ .kb-invoices tr {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'no status' 'date amount' 'open open' 'actions actions';
+ column-gap: 12px; row-gap: 3px; margin-bottom: 10px; padding: 14px 16px 16px; border-radius: 18px;
+ background: var(--kb-surface); border: 1px solid var(--kb-border);
+ }
+ .kb-invoices td { display: block; padding: 0; border: none; }
+ .kb-invoices .inv-no { grid-area: no; align-self: center; font-size: 15px; font-weight: 700; }
+ .kb-invoices .inv-no::before { content: 'Rechnung '; }
+ .kb-invoices .inv-status { grid-area: status; justify-self: end; align-self: center; }
+ .kb-invoices .inv-date { grid-area: date; align-self: end; font-size: 13px; color: var(--kb-muted); }
+ .kb-invoices .inv-amount { grid-area: amount; justify-self: end; font-size: 18px; font-weight: 750; font-variant-numeric: tabular-nums; }
+ .kb-invoices .inv-open { grid-area: open; font-size: 13px; color: #fca5a5; }
+ .kb-invoices .inv-open::before { content: 'Offen: '; }
+ .kb-invoices tr.is-paid .inv-open, .kb-invoices .inv-sep { display: none; }
+ .kb-invoices .inv-actions { grid-area: actions; display: flex; gap: 8px; margin-top: 12px; white-space: normal !important; }
+ .kb-invoices .inv-actions .btn-link {
+ flex: 1; display: flex; align-items: center; justify-content: center; padding: 11px 12px; border-radius: 12px;
+ border: 1px solid var(--kb-border-strong); color: #fff; font-size: 14px; font-weight: 600; white-space: nowrap; text-decoration: none;
+ }
+ .kb-invoices .inv-actions .btn-link:first-child { background: rgba(157, 61, 254, 0.18); border-color: var(--kb-purple-line); }
+ .kb-invoices .inv-actions .btn-link:first-child:not(:last-child) { flex: 1.7; }
+
+ /* Chat: fuellt den Platz zwischen App-Leiste und Tab-Leiste (bzw. Tastatur) */
+ #view-chat.active {
+ display: flex; flex-direction: column;
+ height: calc(var(--kb-vvh, 100dvh) - var(--kb-appbar-h) - var(--kb-tabbar-h) - 20px);
+ }
+ #view-chat .kb-head { flex: none; margin-bottom: 12px; }
+ #view-chat .chat-box { flex: 1 1 auto; min-height: 0; height: auto; padding: 10px; border-radius: 20px; }
+ .chat-messages { overscroll-behavior: contain; padding: 4px 2px; }
+ .chat-bubble { max-width: 84%; font-size: 15px; }
+ .chat-input-row { gap: 8px; margin-top: 10px; }
+ .chat-input-row textarea { box-sizing: border-box; min-height: 46px; padding: 11px 16px; border-radius: 23px; font-size: 16px; line-height: 1.35; }
+ /* Senden als runder Pfeil-Button; der Text bleibt fuer Screenreader erhalten */
+ .chat-send {
+ flex: none; width: 46px; height: 46px; padding: 0; border-radius: 50%; font-size: 0; color: transparent;
+ background: var(--kb-purple) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 19V5M5 12l7-7 7 7'/%3E%3C/svg%3E") center / 22px no-repeat;
+ }
+
+ /* Tab "Wartung": Abo-Karte gross; darunter Unterseiten Stundenkonto/Bestellung und Gutschrift */
+ #kb-abo-slot:has(> .kb-abo:not(.hidden)) > .kb-empty { display: none; }
+ #view-wartung .kb-abo { padding: 20px 18px 18px; border-radius: 20px; }
+ #view-wartung .kb-abo h3 { font-size: 22px; }
+ #view-wartung .kb-price strong, #view-wartung .kb-hours strong { font-size: 32px; }
+ #view-wartung .kb-price span, #view-wartung .kb-hours span, #view-wartung .kb-sub { font-size: 14px; }
+ #view-wartung .kb-checks { gap: 9px; font-size: 14.5px; }
+ #view-wartung .kb-fine { font-size: 12.5px; }
+ #view-wartung .kb-side-alert { font-size: 13.5px; }
+ #view-wartung .kb-side-btn { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 14px 16px; border-radius: 14px; font-size: 15px; }
+ #view-wartung .kb-side-btn .kb-badge { float: none; flex: none; margin: 0 0 0 auto; }
+ #view-wartung .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
+ .kb-stats { gap: 10px; }
+ .kb-stat strong { font-size: 24px; }
+ .abo-summary, .abo-summary tbody, .abo-summary tr, .abo-summary td { display: block; width: auto !important; }
+ .abo-summary tr { padding: 9px 0; border-bottom: 1px solid var(--kb-border); }
+ .abo-summary td { padding: 0; border: none; }
+ .abo-summary td:first-child {
+ margin-bottom: 3px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint);
+ }
+ #abo-order .kb-actions { flex-direction: column; align-items: stretch; }
+ #abo-order .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
+ .abo-actions .abo-btn { flex: 1 1 auto; }
+
+ .kb-legal { justify-content: center; gap: 10px 16px; margin-top: 32px; text-align: center; }
+
+ /* Konto als Bottom-Sheet */
+ .kb-install:not(.hidden) { display: flex; }
+ .kb-sheet { inset: auto 0 0 0; width: 100%; max-width: none; margin: 0; }
+ .kb-sheet::backdrop { background: rgba(0, 0, 0, 0.55); }
+ .kb-sheet-panel {
+ max-height: calc(var(--kb-vvh, 100dvh) - env(safe-area-inset-top) - 24px); overscroll-behavior: contain;
+ padding: 8px max(18px, env(safe-area-inset-right)) calc(18px + env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
+ border-radius: 24px 24px 0 0; border-bottom: none; box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
+ transition: transform 0.24s ease-in;
+ }
+ .kb-sheet[open] .kb-sheet-panel { animation: kb-sheet-up 0.32s cubic-bezier(0.2, 0.9, 0.3, 1); }
+ .kb-sheet.is-closing .kb-sheet-panel { opacity: 1; transform: translateY(100%); }
+ .kb-sheet-panel.is-dragging { transition: none; }
+ .kb-sheet-grip { display: block; width: 40px; height: 5px; margin: 2px auto 12px; border-radius: 3px; background: rgba(255, 255, 255, 0.24); }
+ .kb-sheet-grip, .kb-sheet-me { touch-action: none; }
+ .kb-sheet-links a, .kb-sheet-logout { padding-top: 14px; padding-bottom: 14px; }
+}
+
+/* Touch: Tastatur-Hinweise zum Chat braucht es nicht */
+@media (pointer: coarse) {
+ .chat-hint { display: none; }
+}
+
+/* Handy quer: niedrige Tab-Leiste, Symbol und Text nebeneinander */
+@media (max-width: 880px) and (max-height: 500px), (pointer: coarse) and (max-height: 500px) {
+ .kb-page { --kb-tabbar-h: calc(46px + env(safe-area-inset-bottom)); }
+ .kb-nav { padding-top: 4px; padding-bottom: calc(4px + env(safe-area-inset-bottom)); }
+ .kb-nav-item { flex-direction: row; gap: 6px; max-width: 160px; padding: 6px 0; }
+ .kb-nav-item::before { display: none; }
+ .kb-nav-item.active { color: #d4b4ff; }
+ .kb-tab-ico { margin-top: 0; }
+ .kb-nav-item .kb-badge { position: static; margin-left: -2px; }
+ /* Chat: Ueberschrift weglassen (steht dann in der App-Leiste), einzeilige Eingabe */
+ #view-chat .kb-head { display: none; }
+ .chat-input-row textarea { height: 46px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .kb-shell.dashboard-content-reveal, .kb-view.active, .kb-sheet[open] .kb-sheet-panel, #loading:not(.hidden) { animation: none !important; }
+ .kb-header, .kb-nav, .kb-logo, .kb-app-title, .kb-sheet-panel { transition: none !important; }
+}
diff --git a/public/legal-form.css b/public/legal-form.css
index 2b1fa52..cc5d300 100644
--- a/public/legal-form.css
+++ b/public/legal-form.css
@@ -27,3 +27,9 @@ legend { font-weight: 600; font-size: 14px; margin: 14px 0 4px; }
#effective-date { margin-top: 6px; max-width: 220px; }
.print-btn { margin-top: 14px; background: #fff; color: #1d1d1f; border: 1px solid #cfcac2; border-radius: 10px; padding: 10px 16px; font: inherit; cursor: pointer; }
@media print { .print-btn, .no-print { display: none !important; } body { background: #fff; } .card { border: none; } }
+/* Installierte App (Homescreen): Platz fuer die Statusleiste und ein Weg zurueck in den Kundenbereich */
+.app-back { display: none; }
+@media (display-mode: standalone) {
+ main { padding-top: max(48px, calc(env(safe-area-inset-top) + 20px)); }
+ .app-back { display: inline-block; margin-bottom: 18px; font-size: 15px; font-weight: 600; text-decoration: none; }
+}
diff --git a/public/login.css b/public/login.css
index 8395a51..8db3562 100644
--- a/public/login.css
+++ b/public/login.css
@@ -595,3 +595,41 @@
margin-top: 0;
}
}
+
+/* Login als App (Handy): App-Icon ueber dem Formular, Abstand zu Statusleiste und Home-Balken */
+.login-app-brand {
+ display: none;
+}
+
+@media (max-width: 48em) {
+ .login-header {
+ padding-top: calc(24px + env(safe-area-inset-top));
+ }
+
+ .login-panel {
+ padding-top: calc(88px + env(safe-area-inset-top));
+ padding-bottom: calc(40px + env(safe-area-inset-bottom));
+ }
+
+ .login-app-brand {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ margin: 0 0 40px;
+ font-size: 0.9375rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ }
+
+ .login-app-brand img {
+ width: 64px;
+ height: 64px;
+ border-radius: 16px;
+ box-shadow: 0 10px 30px rgba(157, 61, 254, 0.35);
+ }
+
+ .auth-label {
+ margin-bottom: 28px;
+ }
+}
diff --git a/public/login.html b/public/login.html
index 65e9a6f..734834e 100644
--- a/public/login.html
+++ b/public/login.html
@@ -2,8 +2,18 @@
-
+
Anmelden – Webklar Kundenbereich
+
+
+
+
+
+
+
+
+
+
@@ -18,6 +28,11 @@
+
+
+
+
WEBklar Kundenbereich
+
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..2ef9e43
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,24 @@
+{
+ "id": "/dashboard.html",
+ "name": "WEBklar Kundenbereich",
+ "short_name": "WEBklar",
+ "description": "Projekte, Fortschritt, Rechnungen, Chat und Wartung – der WEBklar-Kundenbereich als App.",
+ "lang": "de",
+ "dir": "ltr",
+ "start_url": "/dashboard.html",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#000000",
+ "theme_color": "#000000",
+ "categories": ["business", "productivity"],
+ "icons": [
+ { "src": "/img/app-icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/img/app-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/img/app-icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ],
+ "shortcuts": [
+ { "name": "Chat", "short_name": "Chat", "url": "/dashboard.html#chat", "icons": [{ "src": "/img/app-icon-192.png", "sizes": "192x192", "type": "image/png" }] },
+ { "name": "Rechnungen", "short_name": "Rechnungen", "url": "/dashboard.html#rechnungen", "icons": [{ "src": "/img/app-icon-192.png", "sizes": "192x192", "type": "image/png" }] },
+ { "name": "Wartung", "short_name": "Wartung", "url": "/dashboard.html#wartung", "icons": [{ "src": "/img/app-icon-192.png", "sizes": "192x192", "type": "image/png" }] }
+ ]
+}
diff --git a/public/offline.html b/public/offline.html
new file mode 100644
index 0000000..4f7bac7
--- /dev/null
+++ b/public/offline.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
Offline – WEBklar Kundenbereich
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Internetverbindung
+ Der Kundenbereich braucht eine Verbindung. Sobald du wieder online bist, geht es hier weiter.
+ Erneut versuchen
+
+
+
+
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..357d8ba
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,41 @@
+// Service Worker des Kundenbereichs: macht die App installierbar (Homescreen) und zeigt ohne
+// Internet eine Offline-Seite. Kundendaten (Seiten, API, PDFs) werden bewusst NICHT
+// zwischengespeichert - alles kommt immer frisch vom Server. Bei Aenderungen CACHE hochzaehlen.
+const CACHE = 'kb-offline-v1'
+const OFFLINE_URL = '/offline.html'
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE)
+ .then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' })))
+ .then(() => self.skipWaiting())
+ )
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil((async () => {
+ for (const key of await caches.keys()) {
+ if (key !== CACHE) await caches.delete(key)
+ }
+ // Seitenaufrufe starten parallel zum Hochfahren des Workers (kein Zeitverlust)
+ if (self.registration.navigationPreload) await self.registration.navigationPreload.enable()
+ await self.clients.claim()
+ })())
+})
+
+// Nur Seitenaufrufe: Netz zuerst, bei fehlender Verbindung die Offline-Seite.
+// Alle anderen Anfragen (API, Bilder, Downloads) laufen unveraendert ans Netz.
+self.addEventListener('fetch', (event) => {
+ const { request } = event
+ if (request.mode !== 'navigate' || request.method !== 'GET') return
+ event.respondWith((async () => {
+ try {
+ const preloaded = await event.preloadResponse
+ if (preloaded) return preloaded
+ return await fetch(request)
+ } catch {
+ const cache = await caches.open(CACHE)
+ return (await cache.match(OFFLINE_URL)) || Response.error()
+ }
+ })())
+})
diff --git a/public/widerrufen.html b/public/widerrufen.html
index c9e98c2..3cd50c5 100644
--- a/public/widerrufen.html
+++ b/public/widerrufen.html
@@ -2,12 +2,14 @@
-
+
Vertrag widerrufen – WEBklar
+
+ ‹ Zurück zum Kundenbereich
WEBKLAR
Vertrag widerrufen