diff --git a/public/app.js b/public/app.js index dc951fa..ebc9759 100644 --- a/public/app.js +++ b/public/app.js @@ -454,19 +454,20 @@ async function loadCustomerInvoices() { body.innerHTML = '

Es liegen noch keine Rechnungen vor.

' return } + // Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css) body.innerHTML = ` -
+
${data.invoices.map((i) => ` - - - - - - - + + + + + + `).join('')} @@ -831,7 +832,7 @@ function renderAboAccountView(view, data) {

Wartungs-Abo

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 ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleisteunter Wartung, damit Ihre Website online bleibt.
' : ''} ${sub.serviceStartsAt ? `
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 ? '' : ''} + ${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 @@
- + + +
+ + +
+ + +
+ +
+ +
+ Mein Konto + +
+ +
+ +
+ + + +
+
+
- +
+ +
+
+

Wartung

+

Betrieb, Pflege und Support für Ihre Website.

+
+

Lade…

+
+
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 @@
Nr.DatumBetragOffenStatus
${escapeHtml(i.number)}${fmtDateShort(i.date)}${fmtEuro(i.amount)}${fmtEuro(i.balance)}${escapeHtml(i.status)} - ${i.portalUrl ? `Ansehen / Zahlen · ` : ''} +
${escapeHtml(i.number)}${fmtDateShort(i.date)}${fmtEuro(i.amount)}${fmtEuro(i.balance)}${escapeHtml(i.status)} + ${i.portalUrl ? `Ansehen / Zahlen` : ''} PDF