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:
ClaudeandClaude Opus 5.5 committed 2026-09-26 14:36:29 +00:00
1 parent 06e9a0fe8d
commit 20bf5afe28
17 files changed
+999 -36

No files matched your search

+396 -14
View File
@@ -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
+81 -9
View File
@@ -2,8 +2,19 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<title>Kundenbereich – WEBklar</title>
<!-- Als App auf den Homescreen: Manifest, Icons, Statusleiste (App-Ansicht auf dem Handy, kundenbereich.css) -->
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<meta name="theme-color" content="#000000">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="WEBklar">
<link rel="stylesheet" href="/login.css">
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
@@ -16,26 +27,75 @@
<div class="kb-glow" aria-hidden="true"></div>
<header class="kb-header">
<a class="kb-logo" href="/dashboard.html">WEBKLAR</a>
<!-- App-Ansicht: Zurueck aus Unterseiten der Wartung, Seitentitel beim Scrollen (initAppShell) -->
<button type="button" class="kb-back hidden" id="kb-back" aria-label="Zurück zu Wartung">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
<span>Wartung</span>
</button>
<div class="kb-bar-title">
<a class="kb-logo" href="/dashboard.html"><svg class="kb-logo-mark" viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" fill="#fff"/><g transform="translate(256 256) scale(0.9) translate(-256 -256)"><ellipse cx="256" cy="256" rx="210" ry="150" fill="#111" transform="rotate(-28 256 256)"/><circle cx="356" cy="172" r="18" fill="#fff"/><path transform="translate(256 256) rotate(-28)" d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z" fill="#fff"/></g></svg>WEBKLAR</a>
<span class="kb-app-title" id="kb-app-title" aria-hidden="true"></span>
</div>
<div class="kb-account">
<div class="kb-account-text">
<strong id="customer-name"></strong>
<span id="customer-meta"></span>
</div>
<span class="kb-avatar" id="customer-avatar" aria-hidden="true"></span>
<button type="button" class="kb-avatar" id="customer-avatar" aria-label="Mein Konto" aria-haspopup="dialog" aria-controls="kb-sheet"></button>
<button type="button" class="kb-logout" id="logout-btn">Abmelden</button>
</div>
</header>
<!-- Konto: auf dem Handy als Bottom-Sheet, am Desktop als Popover unter dem Avatar (initAppShell) -->
<dialog class="kb-sheet" id="kb-sheet" aria-label="Mein Konto">
<div class="kb-sheet-panel">
<div class="kb-sheet-grip" aria-hidden="true"></div>
<div class="kb-sheet-me">
<span class="kb-avatar" id="kb-sheet-avatar" aria-hidden="true"></span>
<div class="kb-sheet-me-text">
<strong id="kb-sheet-name">Mein Konto</strong>
<span id="kb-sheet-meta"></span>
</div>
<button type="button" class="kb-sheet-x" data-sheet-close aria-label="Schließen">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- App-Ansicht: hierher wandert die Karte "Dein Ansprechpartner" aus der Seitenleiste -->
<div id="kb-contact-slot"></div>
<div class="kb-install hidden" data-install></div>
<nav class="kb-sheet-links" aria-label="Verträge und Rechtliches">
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
</nav>
<button type="button" class="kb-sheet-logout" id="kb-sheet-logout">Abmelden</button>
</div>
</dialog>
<div class="kb-shell dashboard-content-reveal">
<!-- Seitenleiste: Navigation, Ansprechpartner, Wartungs-Abo -->
<!-- Seitenleiste: Navigation, Ansprechpartner, Wartungs-Abo. In der App-Ansicht (Handy)
wird die Navigation zur Tab-Leiste unten; die Karten wandern in Konto-Sheet bzw. Tab "Wartung". -->
<aside class="kb-side">
<p class="kb-label">Kundenbereich</p>
<nav class="kb-nav" aria-label="Kundenbereich">
<button type="button" class="kb-nav-item active" data-view="projects">Projekte <span class="kb-count" id="projects-count"></span></button>
<button type="button" class="kb-nav-item" data-view="activity">Fortschritt</button>
<button type="button" class="kb-nav-item" data-view="invoices">Rechnungen <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button>
<button type="button" class="kb-nav-item" data-view="chat">Chat <span class="kb-badge hidden" id="chat-badge" title="ungelesene Nachrichten"></span></button>
<button type="button" class="kb-nav-item active" data-view="projects">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 9h18M6.5 6.5h.01M9.5 6.5h.01"/></svg>
<span class="kb-tab-label">Projekte</span> <span class="kb-count" id="projects-count"></span></button>
<button type="button" class="kb-nav-item" data-view="activity">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l3-7 4 14 3-7h4"/></svg>
<span class="kb-tab-label">Fortschritt</span></button>
<button type="button" class="kb-nav-item" data-view="invoices">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>
<span class="kb-tab-label">Rechnungen</span> <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button>
<button type="button" class="kb-nav-item" data-view="chat">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5a2.5 2.5 0 0 1-2.5 2.5H8l-4 4V5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5z"/></svg>
<span class="kb-tab-label">Chat</span> <span class="kb-badge hidden" id="chat-badge" title="ungelesene Nachrichten"></span></button>
<!-- Nur App-Ansicht; am Desktop steht die Wartung als Karte in der Seitenleiste -->
<button type="button" class="kb-nav-item kb-tab-wartung hidden" data-view="wartung" id="kb-tab-wartung">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5.5c0 4.3-2.9 8.1-7 9.5-4.1-1.4-7-5.2-7-9.5V6z"/><path d="M9 12l2 2 4-4"/></svg>
<span class="kb-tab-label">Wartung</span> <span class="kb-badge hidden" id="kb-tab-wartung-badge"></span></button>
</nav>
<section class="kb-card kb-contact" aria-label="Ansprechpartner">
@@ -59,6 +119,8 @@
<h1>Projekte</h1>
<p id="projects-subtitle">Deine Website – Vorschau, Status und Details.</p>
</header>
<!-- App-Ansicht im Browser: Hinweis "Zum Homescreen hinzufügen" (ausblendbar) -->
<div class="kb-install kb-install-card hidden" data-install data-install-dismissible></div>
<p id="loading" class="kb-empty">Projekte werden geladen…</p>
<ul id="projects" class="kb-projects hidden"></ul>
<div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div>
@@ -80,7 +142,7 @@
<p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p>
</header>
<div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div>
<p class="kb-note hidden" id="invoices-abo-note">Rechnungen zum Wartungs-Abo findest du unter „Abo verwalten &amp; Rechnungen“ in der Seitenleiste.</p>
<p class="kb-note hidden" id="invoices-abo-note">Rechnungen zum Wartungs-Abo findest du unter „Abo verwalten &amp; Rechnungen“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">im Bereich <a href="#wartung" data-view="wartung">Wartung</a></span>.</p>
</section>
<!-- Chat -->
@@ -97,6 +159,16 @@
</div>
</section>
<!-- App-Ansicht, Tab "Wartung": die Abo-Karte der Seitenleiste als Übersicht;
Stundenkonto bzw. Bestellung und Übernahmegutschrift öffnen sich als Unterseiten -->
<section class="kb-view" id="view-wartung">
<header class="kb-head">
<h1>Wartung</h1>
<p>Betrieb, Pflege und Support für Ihre Website.</p>
</header>
<div id="kb-abo-slot"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Wartungs-Abo: Bestellung bzw. Stundenkonto (app.js) -->
<section class="kb-view" id="view-abo" aria-live="polite"></section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect x="48" y="48" width="416" height="416" rx="92" fill="#FFFFFF"/>
<ellipse cx="256" cy="256" rx="210" ry="150" fill="#111111" transform="rotate(-28 256 256)"/>
<circle cx="356" cy="172" r="18" fill="#FFFFFF"/>
<g transform="translate(256 256) rotate(-28)">
<path d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z" fill="#FFFFFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+3 -1
View File
@@ -2,12 +2,14 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Verträge hier kündigen – WEBklar</title>
<link rel="stylesheet" href="/legal-form.css">
</head>
<body>
<main>
<!-- Nur in der installierten App sichtbar: dort gibt es keinen Zurück-Knopf des Browsers -->
<a class="app-back no-print" href="/dashboard.html">‹ Zurück zum Kundenbereich</a>
<div class="brandline">WEBKLAR</div>
<h1>Verträge hier kündigen</h1>
+330 -7
View File
@@ -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; }
}
+6
View File
@@ -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; }
}
+38
View File
@@ -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;
}
}
+23 -4
View File
@@ -2,8 +2,18 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Anmelden – Webklar Kundenbereich</title>
<!-- Als App auf den Homescreen (siehe dashboard.html) -->
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<meta name="theme-color" content="#09090b">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="WEBklar">
<link rel="stylesheet" href="/login.css">
</head>
<body class="portal-page login-page">
@@ -18,6 +28,11 @@
<main class="login-main">
<div class="login-panel">
<div class="AuthForm login-content-reveal">
<!-- Handy: App-Icon wie beim Start einer App -->
<div class="login-app-brand">
<img src="/img/app-icon-192.png" alt="" width="64" height="64">
<span>WEBklar Kundenbereich</span>
</div>
<div class="login-section">
<p class="auth-label">Anmelden</p>
<form id="login-form" class="login-form" novalidate>
@@ -29,6 +44,9 @@
placeholder="E-Mail"
required
autocomplete="username"
autocapitalize="none"
spellcheck="false"
enterkeyhint="next"
>
<input
class="auth-input"
@@ -38,6 +56,7 @@
placeholder="Passwort"
required
autocomplete="current-password"
enterkeyhint="go"
>
<p id="login-error" class="error hidden"></p>
<button type="submit" id="login-btn" class="auth-button login-button">
@@ -57,9 +76,9 @@
<p class="login-hint">
<a href="/kuendigen.html" style="color:#fff;font-weight:700">Verträge hier kündigen</a> ·
<a href="/widerrufen.html" style="color:#fff;font-weight:700">Vertrag widerrufen</a><br>
<a href="/abo-bedingungen.html" style="color:inherit">Vertragsbedingungen</a> ·
<a href="/datenschutz.html" style="color:inherit">Datenschutz</a> ·
<a href="https://webklar.com/impressum" style="color:inherit">Impressum</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener" style="color:inherit">Vertragsbedingungen</a> ·
<a href="/datenschutz.html" target="_blank" rel="noopener" style="color:inherit">Datenschutz</a> ·
<a href="https://webklar.com/impressum" target="_blank" rel="noopener" style="color:inherit">Impressum</a>
</p>
</div>
</div>
+24
View File
@@ -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" }] }
]
}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#000000">
<title>Offline – WEBklar Kundenbereich</title>
<!-- Wird vom Service Worker (sw.js) ohne Internet angezeigt: alles inline, keine weiteren Dateien -->
<style>
html, body { height: 100%; margin: 0; }
body {
display: grid; place-items: center; padding: 24px; box-sizing: border-box;
background: radial-gradient(ellipse at 50% 0%, rgba(157, 61, 254, 0.22), transparent 60%), #000;
color: #fff; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-align: center; line-height: 1.5;
}
main { max-width: 340px; }
svg { width: 72px; height: 72px; border-radius: 18px; }
h1 { margin: 22px 0 8px; font-size: 22px; }
p { margin: 0 0 26px; color: rgba(255, 255, 255, 0.6); font-size: 15px; }
button {
padding: 13px 26px; border: none; border-radius: 999px; background: #9d3dfe; color: #fff;
font: inherit; font-weight: 700; font-size: 15px; cursor: pointer;
}
button:active { transform: scale(0.97); }
</style>
</head>
<body>
<main>
<svg viewBox="0 0 512 512" aria-hidden="true">
<rect width="512" height="512" fill="#fff"/>
<g transform="translate(256 256) scale(0.9) translate(-256 -256)">
<ellipse cx="256" cy="256" rx="210" ry="150" fill="#111" transform="rotate(-28 256 256)"/>
<circle cx="356" cy="172" r="18" fill="#fff"/>
<path transform="translate(256 256) rotate(-28)" d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z" fill="#fff"/>
</g>
</svg>
<h1>Keine Internetverbindung</h1>
<p>Der Kundenbereich braucht eine Verbindung. Sobald du wieder online bist, geht es hier weiter.</p>
<button type="button" onclick="location.reload()">Erneut versuchen</button>
</main>
<script>
window.addEventListener('online', () => location.reload())
</script>
</body>
</html>
+41
View File
@@ -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()
}
})())
})
+3 -1
View File
@@ -2,12 +2,14 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Vertrag widerrufen – WEBklar</title>
<link rel="stylesheet" href="/legal-form.css">
</head>
<body>
<main>
<!-- Nur in der installierten App sichtbar: dort gibt es keinen Zurück-Knopf des Browsers -->
<a class="app-back no-print" href="/dashboard.html">‹ Zurück zum Kundenbereich</a>
<div class="brandline">WEBKLAR</div>
<h1>Vertrag widerrufen</h1>