// Sitzung beendet (Passwort auf einem anderen Gerät geändert): nur einmal zur Anmeldung wechseln let sessionEndRedirected = false async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const fallback = response.status === 502 ? 'Server nicht erreichbar (502). Bitte kurz warten und Seite neu laden – ggf. läuft ein Update auf project.webklar.com.' : `Fehler ${response.status}` const err = new Error(data.error || fallback) err.status = response.status if (data.code) err.code = data.code if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds // Passwort auf einem anderen Gerät geändert: diese Sitzung gilt nicht mehr if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) { sessionEndRedirected = true window.location.href = `/login.html?error=${encodeURIComponent(err.message)}` } throw err } return data } function showError(el, message) { if (!el) return el.textContent = message el.classList.remove('hidden') } function hideError(el) { if (el) el.classList.add('hidden') } function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeAttr(str) { return escapeHtml(str).replace(/'/g, ''') } function getQueryParam(name) { return new URLSearchParams(window.location.search).get(name) || '' } function fmtDateShort(value) { if (!value) return '–' if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value) const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtDateTime(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtEuro(value) { return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) } function fmtBytes(bytes) { const n = Number(bytes || 0) if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` if (n >= 1024) return `${(n / 1024).toFixed(1)} KB` return `${n} B` } function fmtMinutes(min) { const n = Number(min || 0) if (!n) return '' const h = Math.floor(n / 60) const m = n % 60 if (!h) return `${m} min` return m ? `${h} h ${m} min` : `${h} h` } /* -------------------------------------------------------------- Projekte --- */ function featuresForProject(features, projectId) { return features.filter((f) => !f.projectId || f.projectId === projectId) } const PREVIEW_BASE_WIDTH = 1280 function setupPreviewFrame(frame) { const view = frame.querySelector('iframe') if (!view) return const apply = () => { const w = frame.clientWidth if (w) view.style.transform = `scale(${w / PREVIEW_BASE_WIDTH})` } if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(apply).observe(frame) } else { window.addEventListener('resize', apply) } apply() } function fmtDateTimeLong(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } // "autohaus-meier.project.webklar.com" -> Subdomain hervorheben, Rest gedimmt function previewHostHtml(url) { try { const host = new URL(url).hostname const dot = host.indexOf('.') return dot > 0 ? `${escapeHtml(host.slice(0, dot))}${escapeHtml(host.slice(dot))}` : escapeHtml(host) } catch { return '–' } } function projectState(project) { if (isProjectPreviewReady(project)) return ['kb-state-ok', 'Vorschau online'] const s = String(project.provisioningStatus || project.status || '').toLowerCase() if (s.includes('fail')) return ['kb-state-off', 'Vorschau gestört'] if (project.previewUrl) return ['kb-state-wait', 'Vorschau wird vorbereitet'] return ['kb-state-off', 'Noch keine Website'] } const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' } function renderProjectCard(project, features) { const li = document.createElement('li') li.className = 'kb-project' const ready = isProjectPreviewReady(project) const url = project.previewUrl ? escapeAttr(project.previewUrl) : '' const [stateClass, stateText] = projectState(project) const extraChips = featuresForProject(features, project.id) .map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey) .filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label)) li.innerHTML = ` ${ready && url ? ` Website öffnen ↗ ` : `
${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht'}
`}

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

${stateText}
Vorschau-Adresse
${project.previewUrl ? previewHostHtml(project.previewUrl) : '–'}
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : 'noch nicht verbunden'}
Letzte Änderung
${project.updatedAt ? fmtDateTimeLong(project.updatedAt) : '–'}
${ready && url ? `Vorschau` : ''} ${extraChips.map((label) => `${escapeHtml(label)}`).join('')}
` const frame = li.querySelector('[data-preview-frame]') if (frame) setupPreviewFrame(frame) // Am Desktop klappen die Details in der Karte auf; in der App-Ansicht (Handy) oeffnen sie sich // als eigene Seite mit Zurueck-Pfeil (openProjectPage) const btn = li.querySelector('[data-details-btn]') const label = li.querySelector('[data-details-label]') const box = li.querySelector('[data-details-box]') let loaded = false btn.addEventListener('click', () => { if (isAppLayout()) { openProjectPage(project) return } const opening = box.classList.contains('hidden') box.classList.toggle('hidden', !opening) btn.setAttribute('aria-expanded', String(opening)) label.textContent = opening ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen' if (opening && !loaded) { loaded = true loadProjectDetails(project, box) } }) return li } /* --------------------------------------------------------- Projektdetails --- */ // Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine // Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch. // Dieselben Bausteine nutzt der Tab "Fortschritt" (loadCustomerActivity). const PD_ICONS = { update: '', online: '', onsite: '', office: '', work: '', clock: '', changes: '', files: '', } const ORDER_STEPS = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'] const PD_CHANGES_SHOWN = 5 const PD_ENTRIES_SHOWN = 4 function pdIcon(name, cls = 'pd-ico') { return `` } // "2026-09-20" -> "20.09.2026" (reines Datum, ohne Zeitzonen-Umrechnung) function fmtIsoDay(day) { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(day || '')) return m ? `${m[3]}.${m[2]}.${m[1]}` : '' } // Ueberschrift je Tag in der Zeitleiste: "Heute", "Gestern", sonst "Mittwoch, 24. September" function changeDayLabel(date) { const today = new Date() const dayStart = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() const diff = Math.round((dayStart(today) - dayStart(date)) / 86400000) if (diff === 0) return 'Heute' if (diff === 1) return 'Gestern' return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', ...(date.getFullYear() === today.getFullYear() ? {} : { year: 'numeric' }), }) } // Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt function renderOrder(order) { const { status } = order const steps = status.step > 0 ? `
    ${ORDER_STEPS.map((label, i) => { const n = i + 1 const state = n < status.step ? 'is-done' : n === status.step ? 'is-current' : '' return `
  1. ${label}
  2. ` }).join('')}
` : '' const hint = status.tone === 'waiting' ? '

Wir brauchen noch eine Info von dir.

' : '' return `
${order.number ? `Auftrag ${escapeHtml(order.number)}` : ''}

${escapeHtml(order.topic || 'Dein Auftrag')}

${escapeHtml(status.label)}
${steps}${hint}` } // Lange Listen: nur die ersten Eintraege zeigen, "3 weitere … anzeigen" klappt den Rest auf (initPdToggles) function pdMoreButton(total, shown, [one, many]) { const rest = total - shown if (rest <= 0) return '' const text = `${rest} ${rest === 1 ? one : many} anzeigen` return `` } function renderChange(c, extra) { const d = new Date(c.date) const time = Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) return `
  • ${escapeHtml(c.title)} ${time ? `` : ''}
    ${c.details?.length ? `
      ${c.details.map((t) => `
    • ${escapeHtml(t)}
    • `).join('')}
    ` : ''}
  • ` } // Zeitleiste der letzten Aenderungen, nach Tagen gruppiert function renderChanges(changes) { if (!changes.length) return '

    Noch keine Änderungen an der Website.

    ' const groups = [] changes.forEach((c, i) => { const d = new Date(c.date) const day = Number.isNaN(d.getTime()) ? '' : changeDayLabel(d) if (!groups.length || groups[groups.length - 1].day !== day) groups.push({ day, first: i, items: [] }) groups[groups.length - 1].items.push(renderChange(c, i >= PD_CHANGES_SHOWN)) }) return `
    ${groups.map((g) => `
    = PD_CHANGES_SHOWN ? ' data-extra' : ''}> ${g.day ? `

    ${escapeHtml(g.day)}

    ` : ''}
      ${g.items.join('')}
    `).join('')}
    ${pdMoreButton(changes.length, PD_CHANGES_SHOWN, ['weitere Änderung', 'weitere Änderungen'])}
    ` } function renderWorkEntry(e, extra) { const meta = [fmtIsoDay(e.date), e.person].filter(Boolean).map(escapeHtml).join(' · ') const body = e.changes?.length ? `
      ${e.changes.map((t) => `
    • ${escapeHtml(t)}
    • `).join('')}
    ` : e.text ? `

    ${escapeHtml(e.text)}

    ` : '' return `
  • ${pdIcon(e.kind)}
    ${escapeHtml(e.label)} ${meta ? `` : ''}
    ${body}
    ${e.minutes ? `${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}` : ''}
  • ` } function renderWorkEntries(entries, emptyText) { if (!entries.length) return `

    ${emptyText}

    ` return `
      ${entries.map((e, i) => renderWorkEntry(e, i >= PD_ENTRIES_SHOWN)).join('')}
    ${pdMoreButton(entries.length, PD_ENTRIES_SHOWN, ['weiteren Eintrag', 'weitere Einträge'])}
    ` } function initPdToggles(root) { root.querySelectorAll('[data-pd-more]').forEach((btn) => btn.addEventListener('click', () => { const wrap = btn.closest('.pd-collapsible') const open = wrap.classList.toggle('is-open') btn.textContent = open ? 'Weniger anzeigen' : btn.dataset.moreLabel })) } function renderFileOverview(files) { if (!files || !files.totalFiles) { return '

    Keine Datei-Informationen verfügbar.

    ' } const rows = (files.topLevel || []).map((entry) => ` ${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)} ${entry.fileCount} ${fmtBytes(entry.sizeBytes)} `).join('') return `

    ${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}

    ${rows}
    Ordner / DateiDateienGröße
    ` } function pdSection(icon, title, body) { return `

    ${pdIcon(icon)}${title}

    ${body}
    ` } // stillWanted: die Handy-Seite teilt sich einen Container fuer alle Projekte; eine spaete Antwort fuer // ein inzwischen verlassenes Projekt darf die Seite des naechsten nicht ueberschreiben async function loadProjectDetails(project, box, { stillWanted = () => true } = {}) { box.innerHTML = '

    Details werden geladen…

    ' const [git, files, work] = await Promise.all([ api(`/api/projects/${encodeURIComponent(project.id)}/git`).catch(() => null), api(`/api/projects/${encodeURIComponent(project.id)}/files`).catch(() => null), api(`/api/projects/${encodeURIComponent(project.id)}/work`).catch(() => null), ]) if (!stillWanted()) return const order = work?.order box.innerHTML = ` ${order ? `
    ${renderOrder(order)}
    ` : ''} ${pdSection('changes', 'Letzte Änderungen', git ? renderChanges(git.changes || []) : '

    Die Änderungen können gerade nicht geladen werden.

    ')} ${order || !work ? pdSection('work', 'Arbeiten am Projekt', work ? renderWorkEntries(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.') : '

    Die Arbeiten können gerade nicht geladen werden.

    ') : ''} ${pdSection('files', 'Dateien', files ? renderFileOverview(files) : '

    Die Datei-Übersicht kann gerade nicht geladen werden.

    ')}` initPdToggles(box) } // App-Ansicht: Projektdetails als Unterseite von "Projekte" (Zurueck-Pfeil, eigener Verlaufseintrag). // Dasselbe Projekt erneut oeffnen zeigt die schon geladenen Details. const projectPage = { project: null } function openProjectPage(project) { const view = document.getElementById('view-projekt') if (!view) return const reload = projectPage.project !== project projectPage.project = project view.querySelector('.kb-head h1').textContent = project.projectName || project.subdomain || 'Projekt' showView('projekt') if (reload) loadProjectDetails(project, view.querySelector('.kb-details'), { stillWanted: () => projectPage.project === project }) } /* -------------------------------------------------------- Login-Redirects --- */ function safeRedirectUrl(url) { try { const u = new URL(url) if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') { return u.toString() } } catch { /* ignore invalid URL */ } return null } function isProjectPreviewReady(project) { const status = (project?.provisioningStatus || project?.status || '').toLowerCase() return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl) } function previewSubdomainFromUrl(url) { try { const host = new URL(url).hostname.toLowerCase() const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/) return match ? match[1] : '' } catch { return '' } } async function canAccessPreviewUrl(url) { const subdomain = previewSubdomainFromUrl(url) if (!subdomain) return false try { const access = await fetch( `/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`, { credentials: 'same-origin' } ).then((r) => r.json()) return Boolean(access.allowed) } catch { return false } } async function resolvePostLoginTarget() { // Direktlink auf eine Ansicht im Kundenbereich (z. B. /dashboard.html#gutschrift) const raw = getQueryParam('next') if (/^\/dashboard\.html(#[a-z]+)?$/.test(raw)) return raw const next = safeRedirectUrl(raw) if (next && (await canAccessPreviewUrl(next))) { return next } return '/dashboard.html' } async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } // Links aus Admin-Mails (z. B. /admin.html?gutschrift=) nach dem Login wieder aufnehmen function adminLoginTarget() { const next = getQueryParam('next') return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html' } /* -------------------------------------------------------------- Login-Page --- */ async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') const errorFromQuery = getQueryParam('error') if (errorFromQuery) { showError(errorEl, errorFromQuery) } async function applyCooldown(seconds) { if (seconds <= 0) return showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).` ) btn.disabled = true const tick = setInterval(async () => { const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({})) const left = status.retryAfterSeconds || 0 if (left <= 0) { clearInterval(tick) hideError(errorEl) btn.disabled = false return } showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).` ) }, 1000) } try { const status = await fetch('/api/auth/login-status').then((r) => r.json()) if (status.blocked) { await applyCooldown(status.retryAfterSeconds) } } catch { /* ignore */ } try { const me = await api('/api/auth/me') if (me.authenticated && me.role === 'admin' && !me.viewAs) { window.location.href = adminLoginTarget() return } if (me.authenticated && me.customer) { await postLoginRedirect() return } } catch { /* not logged in */ } form?.addEventListener('submit', async (e) => { e.preventDefault() if (btn.disabled) return errorEl.classList.add('hidden') btn.disabled = true btn.classList.add('is-loading') btn.setAttribute('aria-busy', 'true') let cooldownActive = false try { const result = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: document.getElementById('email').value, password: document.getElementById('password').value, }), }) if (result.role === 'admin') { window.location.href = adminLoginTarget() return } await postLoginRedirect() return } catch (err) { showError(errorEl, err.message) if (err.status === 429) { cooldownActive = true await applyCooldown(err.retryAfterSeconds || 900) } } finally { btn.classList.remove('is-loading') btn.removeAttribute('aria-busy') if (!cooldownActive) btn.disabled = false } }) } /* --------------------------------------------------------------- View-as --- */ function renderViewAsBanner(me) { const banner = document.getElementById('viewas-banner') if (!banner || !me.viewAs) return banner.classList.remove('hidden') banner.innerHTML = ` Admin-Ansicht: Du siehst das Portal als ${escapeHtml(me.customer?.name || 'Kunde')}. ` document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => { await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) window.location.href = '/admin.html' }) } /* ------------------------------------------------------------- Rechnungen --- */ async function loadCustomerInvoices() { const body = document.getElementById('invoices-body') if (!body) return try { const data = await api('/api/invoices') // Zaehler in der Seitenleiste: Rechnungen mit offenem Betrag const openCount = (data.invoices || []).filter((i) => Number(i.balance) > 0).length const badge = document.getElementById('invoices-badge') if (badge) { badge.textContent = openCount badge.classList.toggle('hidden', !openCount) } if (!data.linked) { body.innerHTML = '

    Für dein Konto sind noch keine Rechnungen hinterlegt.

    ' return } if (!data.invoices.length) { 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('')}
    Nr.DatumBetragOffenStatus
    ${escapeHtml(i.number)} ${fmtDateShort(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} ${escapeHtml(i.status)} ${i.portalUrl ? `Ansehen / Zahlen` : ''} PDF
    ` } catch (err) { body.innerHTML = `

    Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}

    ` } } /* -------------------------------------------------------------- Aktivität --- */ // Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten // (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server) async function loadCustomerActivity() { const body = document.getElementById('activity-body') if (!body) return try { const { tickets } = await api('/api/activity') if (!tickets || !tickets.length) { body.innerHTML = '

    Es wurden noch keine Arbeiten dokumentiert.

    ' return } body.innerHTML = tickets.map((t) => `
    ${renderOrder(t)} ${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')}
    `).join('') initPdToggles(body) } catch (err) { body.innerHTML = `

    Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}

    ` } } /* ------------------------------------------------------------------ Chat --- */ const chatState = { lastCreatedAt: '', pollTimer: null, badgeTimer: null, initialized: false, viewAs: false, shownIds: new Set(), composer: null, } const chatFileUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}` function appendChatMessages(messages) { const box = document.getElementById('chat-messages') if (!box || !messages.length) return for (const msg of messages) { // Poll und Antwort auf das Senden koennen dieselbe Nachricht liefern if (chatState.shownIds.has(msg.id)) continue chatState.shownIds.add(msg.id) const mine = msg.sender === 'customer' box.appendChild(ChatComposer.renderMessage(msg, { mine, meta: `${mine ? 'Du' : msg.senderName || 'Webklar-Team'} · ${fmtDateTime(msg.createdAt)}`, fileUrl: chatFileUrl, })) if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt } box.scrollTop = box.scrollHeight } function updateChatBadge(unread) { const badge = document.getElementById('chat-badge') if (!badge) return if (unread > 0) { badge.textContent = unread badge.classList.remove('hidden') } else { badge.classList.add('hidden') } } async function pollChatMessages() { if (document.hidden) return try { const q = chatState.lastCreatedAt ? `?since=${encodeURIComponent(chatState.lastCreatedAt)}` : '' const data = await api(`/api/chat/messages${q}`) appendChatMessages(data.messages || []) if (isChatTabActive() && (data.unread || 0) > 0) { await api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) } } catch { /* naechster Poll versucht es erneut */ } } function isChatTabActive() { return Boolean(document.getElementById('view-chat')?.classList.contains('active')) } async function initChat() { if (chatState.initialized) return chatState.initialized = true ChatComposer.trackScroll(document.getElementById('chat-messages')) // Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js) chatState.composer = ChatComposer.create({ root: document.getElementById('chat-composer'), dropZone: document.querySelector('#view-chat .chat-box'), uploadUrl: '/api/chat/attachments', placeholder: 'Nachricht an das WEBklar-Team…', placeholderShort: 'Nachricht…', disabled: chatState.viewAs, disabledText: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.', send: async ({ text, attachmentIds }) => { const { message } = await api('/api/chat/messages', { method: 'POST', body: JSON.stringify({ text, attachmentIds }), }) return message }, onSent: (message) => appendChatMessages([message]), }) await pollChatMessages() await api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) chatState.pollTimer = setInterval(() => { if (isChatTabActive()) pollChatMessages() }, 10000) } async function pollChatBadge() { if (document.hidden || isChatTabActive()) return try { const { unread } = await api('/api/chat/unread-count') updateChatBadge(unread) } catch { /* ignore */ } } /* -------------------------------------------------------------- Dashboard --- */ // --------------------------------------------------------------------------- // Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto) // --------------------------------------------------------------------------- const ABO_FEATURES = [ ['Betrieb & Pflege Ihrer Website/Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'], ['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'], ['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'], ['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'], ] // Kurzfassung fuer die Karte in der Seitenleiste const ABO_SIDE_FEATURES = ['Betrieb & Pflege, Server in Deutschland', '1 Domain inklusive', '1 E-Mail-Postfach, 5 GB', '1 Std. Arbeitszeit pro Monat'] function fmtHours(min) { const n = Math.max(0, Number(min || 0)) const h = Math.floor(n / 60) const m = n % 60 return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h` } function euroShort(cents) { return cents % 100 === 0 ? `${cents / 100} €` : fmtEuro(cents / 100) } // Fristen gelten nach deutschem Kalender, auch wenn der Kunde gerade im Ausland ist function fmtDayMonth(value) { const d = new Date(value) return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' }) } function aboFeaturesHtml() { return `
      ${ABO_FEATURES.map( ([t, d]) => `
    • ${t}${d}
    • ` ).join('')}
    ` } // Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen) function aboTermText(sub) { if (sub.cancelAtPeriodEnd) { return `Gekündigt zum ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}. Bis dahin bleiben alle Leistungen verfügbar.` } if (sub.minTermEnd && new Date(sub.minTermEnd) > new Date()) { return `Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.` } return 'Monatlich kündbar zum Ende des Abrechnungsmonats.' } /* Seitenleiste: Angebot (noch kein Abo) */ function renderAboSideOffer(card, data, { ended }) { const offer = data.offer card.innerHTML = `

    ${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}

    Wartungs-Abo

    ${offer ? 'Ihr persönliches Angebot' : ''}

    ${offer ? `${euroShort(offer.monthlyPriceCents)}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €` : '50 €pro Monat'}

    ${offer?.validUntil ? `

    Angebot gültig bis ${fmtDateShort(offer.validUntil)}

    ` : ''}
      ${ABO_SIDE_FEATURES.map((f) => `
    • ${escapeHtml(f)}
    • `).join('')}

    ${data.available ? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.' : 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}

    ` } /* Seitenleiste: laufendes Abo */ function renderAboSideActive(card, data) { const sub = data.subscription const l = data.ledger || { balanceMinutes: 0 } // Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text const status = sub.cancelAtPeriodEnd ? 'gekündigt' : '' const dateLine = sub.cancelAtPeriodEnd ? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}` : sub.serviceStartsAt ? `Beginn und erste Abbuchung am ${fmtDateShort(sub.serviceStartsAt)}` : `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}` card.innerHTML = `

    Ihre Wartung

    ${status}

    ${dateLine}

    ${sub.paymentProblem ? `

    Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.

    ` : ''} ${sub.withdrawalOpen ? `Vertrag widerrufen · bis ${fmtDayMonth(sub.withdrawalUntil)}` : ''}

    ${aboTermText(sub)}

    ` card.querySelector('#abo-manage')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, card.querySelector('#abo-error'))) } /* Stripe-Kundenportal (Zahlungsart, Rechnungen, Abo): "Abo verwalten & Rechnungen" im Konto-Menü, in der Wartungskarte nur bei fehlgeschlagener Abbuchung ("Zahlungsart aktualisieren") */ async function openBillingPortal(btn, errorEl) { btn.disabled = true hideError(errorEl) try { const { url } = await api('/api/abo/portal', { method: 'POST' }) window.location.href = url } catch (err) { showError(errorEl, err.message) btn.disabled = false } } /* Ansicht "Wartungs-Abo": Bestellung (Bestellübersicht direkt über dem Bestellbutton, § 312j BGB) */ function renderAboOrderView(view, data) { const available = data.available const offer = data.offer view.innerHTML = `

    Wartungs-Abo

    Betrieb, Pflege und Support für Ihre Website – ${offer ? 'mit Ihrem persönlichen Angebot' : 'für 50 € im Monat'}.

    ${offer ? `
    Ihr persönliches Angebot

    ${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.

    ${offer.note ? `

    ${escapeHtml(offer.note)}

    ` : ''}

    Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt

    ` : ''}
    ${aboFeaturesHtml()}

    Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.

    ${available ? `

    Ihre Bestellung

    ${data.testAccount ? `
    Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}
    ` : ''}
    Wann sollen wir mit der Leistung beginnen?

    Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.

    LeistungWEBklar Wartungs-Abo: Betrieb & Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)
    Preis${offer ? `${escapeHtml(offer.text)} (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten` : '50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}
    Abrechnungmonatlich im Voraus, erstmals zum Leistungsbeginn
    LaufzeitMindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar
    ZahlungKreditkarte oder SEPA-Lastschrift über Stripe
    AnbieterWEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com

    Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: Vertragsbedingungen · Datenschutz

    Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.
    ` : `

    Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.

    `}` view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => { const btn = ev.currentTarget const errEl = view.querySelector('#abo-error') hideError(errEl) const acceptTerms = view.querySelector('#abo-terms').checked const startChoice = view.querySelector('input[name="abo-start-choice"]:checked')?.value || '' if (!acceptTerms) { showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.') return } if (!startChoice) { showError(errEl, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.') return } btn.disabled = true btn.textContent = 'Weiter zur Zahlung…' try { const { url } = await api('/api/abo/checkout', { method: 'POST', body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '', lateStartAt: data.lateStartAt || '' }), }) window.location.href = url } catch (err) { showError(errEl, err.message) btn.disabled = false btn.textContent = 'Zahlungspflichtig bestellen' } }) } /* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */ function renderAboAccountView(view, data) { const sub = data.subscription const l = data.ledger || { balanceMinutes: 0, usedMinutes: 0, credits: [], usage: [] } const usageRows = (l.usage || []).map((u) => ` ${fmtDateShort(u.date)} ${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` #${escapeHtml(u.woid)}` : ''} −${fmtHours(u.minutes)}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') const creditRows = (l.credits || []).slice().reverse().map((c) => ` ${fmtDateShort(c.grantedAt)}Monatskontingent +${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}`).join('') view.innerHTML = `

    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 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
    ${fmtHours(l.usedMinutes)}bisher genutzt
    ${l.nextExpiry ? `
    ${fmtHours(l.nextExpiry.minutes)}verfallen am ${fmtDateShort(l.nextExpiry.at)}
    ` : ''}

    Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. Aufträge schicken Sie uns einfach über den Chat.

    ${sub.offerText ? `

    Ihr Angebot: ${escapeHtml(sub.offerText)}.

    ` : ''}

    ${aboTermText(sub)}

    Leistungen & Bedingungen

    Verlauf

    ${usageRows || creditRows ? `
    ${usageRows}${creditRows}
    DatumVorgangZeit
    ` : '

    Noch keine Buchungen.

    '}
    ` } const aboState = { data: null } async function loadAboCard({ afterCheckout = false } = {}) { const card = document.getElementById('abo-card') const view = document.getElementById('view-abo') if (!card || !view) return try { let data = await api('/api/abo') // Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) { card.classList.remove('hidden') card.innerHTML = '

    Vielen Dank!

    Ihr Abo wird aktiviert…

    Das dauert nur einen Moment.

    ' await new Promise((r) => setTimeout(r, 2000)) data = await api('/api/abo') } aboState.data = data if (data.subscription?.active) { renderAboSideActive(card, data) renderAboAccountView(view, data) } else { renderAboSideOffer(card, data, { ended: Boolean(data.subscription) }) renderAboOrderView(view, data) } card.classList.remove('hidden') document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription) document.getElementById('kb-sheet-abo')?.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 })) } } // --------------------------------------------------------------------------- // Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten // --------------------------------------------------------------------------- const takeoverState = { abo: null, customer: null } const TAKEOVER_STATUS = { offen: ['pill-warn', 'In Prüfung'], rueckfrage: ['pill-amber', 'Rückfrage'], genehmigt: ['pill-ok', 'Genehmigt'], abgelehnt: ['pill-muted', 'Nicht genehmigt'], } // Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen const TAKEOVER_CONDITIONS = [ 'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.', 'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.', 'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.', 'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.', 'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.', ] const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']] function takeoverStatusPill(status) { const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status] return `${label}` } function renderTakeoverItem(r) { const facts = [ `eingereicht am ${fmtDateShort(r.createdAt)}`, `bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`, `${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`, ] let body if (r.status === 'genehmigt') { body = `

    Gutschrift: ${fmtEuro(r.amountCents / 100)} – wird mit Ihren nächsten Monatsgebühren verrechnet.

    ${r.decisionNote ? `

    ${escapeHtml(r.decisionNote)}

    ` : ''}` } else if (r.status === 'abgelehnt') { body = `

    ${escapeHtml(r.decisionNote)}

    ` } else if (r.status === 'rueckfrage') { body = `
    Unsere Rückfrage:
    ${escapeHtml(r.question)}
    ` } else { body = '

    Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.

    ' } return `
    Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}${takeoverStatusPill(r.status)}
    ${facts.join(' · ')}
    ${body}
    ` } function takeoverFormHtml(limits) { const email = takeoverState.customer?.email || '' const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024) return ` ` } function readFileAsBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result).split(',')[1] || '') reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`)) reader.readAsDataURL(file) }) } async function collectTakeoverFiles(input, limits, { required }) { const files = [...(input?.files || [])] const mb = (bytes) => Math.round(bytes / 1024 / 1024) if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.') if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`) const tooBig = files.find((f) => f.size > limits.maxFileBytes) if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`) if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) { throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`) } return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) }))) } async function submitTakeoverForm(form, limits) { const errEl = form.querySelector('#tk-error') const btn = form.querySelector('#tk-submit') hideError(errEl) const value = (id) => form.querySelector(id).value.trim() const payload = { services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value), provider: value('#tk-provider'), domain: value('#tk-domain'), invoiceAmount: value('#tk-amount'), billingMonths: Number(value('#tk-months')), paidUntil: value('#tk-paid-until'), message: value('#tk-message'), email: value('#tk-email'), consent: form.querySelector('#tk-consent').checked, } try { if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.') if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.') if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) { throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.') } if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.') btn.disabled = true btn.textContent = 'Wird gesendet…' payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true }) await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) }) await loadTakeoverCard({ notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`, }) } catch (err) { showError(errEl, err.message) btn.disabled = false btn.textContent = 'Antrag absenden' } } async function submitTakeoverReply(form, limits) { const errEl = form.querySelector('.error') const btn = form.querySelector('button[type="submit"]') hideError(errEl) btn.disabled = true try { const message = form.querySelector('textarea').value.trim() const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false }) if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.') await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, { method: 'POST', body: JSON.stringify({ message, files }), }) await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' }) } catch (err) { showError(errEl, err.message) btn.disabled = false } } function updateTakeoverBadge(requests) { const badge = document.getElementById('takeover-badge') if (!badge) return const open = requests.filter((r) => r.status === 'rueckfrage').length badge.textContent = open badge.classList.toggle('hidden', !open) } async function loadTakeoverCard({ abo, notice = '' } = {}) { const card = document.getElementById('takeover-card') if (!card) return if (abo) takeoverState.abo = abo const active = Boolean(takeoverState.abo?.subscription?.active) let data = { requests: [], limits: null } if (takeoverState.abo?.subscription) { try { data = await api('/api/abo/takeover-credits') } catch (err) { card.innerHTML = `
    Übernahmegutschrift konnte nicht geladen werden: ${escapeHtml(err.message)}
    ` return } } const requests = data.requests || [] updateTakeoverBadge(requests) if (!active && !requests.length) { card.innerHTML = '

    Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft. Zum Wartungs-Abo

    ' return } card.innerHTML = `

    Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.

    ${notice ? `
    ${escapeHtml(notice)}
    ` : ''} ${active ? `
    ${chatState.viewAs ? 'Im Ansichtsmodus kann kein Antrag gestellt werden.' : ''}
    ${takeoverFormHtml(data.limits)}` : ''} ${requests.length ? `
    ${requests.map(renderTakeoverItem).join('')}
    ` : ''}
    ` const form = card.querySelector('#tk-form') const startBtn = card.querySelector('#tk-start') startBtn?.addEventListener('click', () => { form.classList.remove('hidden') startBtn.classList.add('hidden') form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) card.querySelector('#tk-cancel')?.addEventListener('click', () => { form.classList.add('hidden') startBtn?.classList.remove('hidden') }) form?.addEventListener('submit', (ev) => { ev.preventDefault() submitTakeoverForm(form, data.limits) }) card.querySelectorAll('form[data-reply-for]').forEach((f) => f.addEventListener('submit', (ev) => { ev.preventDefault() submitTakeoverReply(f, data.limits) })) } /* -------------------------------------------------------- Passwort ändern --- */ // Seite "Passwort ändern" aus dem Konto-Menü. Dieselben Regeln prüft der Server // (server/services/customerPassword.js); danach sind andere Geräte abgemeldet. function passwordProblem(current, next, repeat, email) { if (!current) return ['pw-current', 'Bitte gib dein aktuelles Passwort ein.'] if (!next) return ['pw-new', 'Bitte gib ein neues Passwort ein.'] if ([...next].length < 8) return ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.'] if (new TextEncoder().encode(next).length > 256) return ['pw-new', 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).'] if (!next.trim()) return ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.'] if (next === current) return ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.'] if (email && next.trim().toLowerCase() === email.trim().toLowerCase()) { return ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.'] } if (next !== repeat) return ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.'] return null } const passwordState = { email: '', ready: false, viewAs: false } // Kunde bekannt (/api/auth/me): Zugang für Passwort-Manager, im Ansichtsmodus gesperrt function setPasswordAccount(customer, viewAs) { passwordState.email = customer.email || '' passwordState.viewAs = viewAs passwordState.ready = true document.getElementById('pw-username').value = passwordState.email if (!viewAs) return const submit = document.getElementById('pw-submit') document.querySelectorAll('#pw-form input').forEach((f) => { f.disabled = true }) submit.disabled = true submit.insertAdjacentHTML('afterend', 'Im Ansichtsmodus kann das Passwort nicht geändert werden.') } // Gleich beim Start: das Formular darf nie ohne Script-Handler abgeschickt werden function initPasswordView() { const form = document.getElementById('pw-form') if (!form) return const done = document.getElementById('pw-done') const errorEl = document.getElementById('pw-error') const submit = document.getElementById('pw-submit') const show = document.getElementById('pw-show') const fields = ['pw-current', 'pw-new', 'pw-repeat'].map((id) => document.getElementById(id)) const [current, next, repeat] = fields let returnTo = 'projects' const clearFields = () => { fields.forEach((f) => { f.value = ''; f.type = 'password' }) show.checked = false hideError(errorEl) } const showDone = (on) => { form.classList.toggle('hidden', on) done.classList.toggle('hidden', !on) } // Wer die Seite verlässt, findet beim nächsten Mal ein leeres Formular (keine Passwörter im DOM) document.addEventListener('kb:view', (ev) => { if (ev.detail.name !== 'passwort') { returnTo = ev.detail.name clearFields() showDone(false) } else if (!passwordState.viewAs && !coarsePointer.matches) { // Am Desktop gleich lostippen; auf dem Handy nicht ungefragt die Tastatur öffnen current.focus({ preventScroll: true }) } }) show.addEventListener('change', () => { fields.forEach((f) => { f.type = show.checked ? 'text' : 'password' }) }) form.addEventListener('submit', async (ev) => { ev.preventDefault() if (submit.disabled || !passwordState.ready || passwordState.viewAs) return hideError(errorEl) const problem = passwordProblem(current.value, next.value, repeat.value, passwordState.email) if (problem) { showError(errorEl, problem[1]) document.getElementById(problem[0]).focus() return } submit.disabled = true submit.textContent = 'Wird gespeichert…' try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ currentPassword: current.value, newPassword: next.value }), }) clearFields() showDone(true) done.focus() } catch (err) { showError(errorEl, err.message) if (err.code === 'wrong_password' || err.code === 'current_missing') { current.focus() current.select() } else if (err.code === 'new_invalid') { next.focus() } } finally { submit.disabled = false submit.textContent = 'Passwort ändern' } }) // Fertig: zurück, woher man kam (App-Ansicht über den Verlauf wie der Zurück-Pfeil) document.getElementById('pw-done-btn')?.addEventListener('click', () => { if (window.history.state?.kbSub === 'passwort') window.history.back() else showView(APP_PARENT.passwort || returnTo) }) } /* ---------------------------------------------------------- Navigation --- */ // 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. // "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht. // "passwort" oeffnet das Konto-Menue (Profilbild). const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' } const viewLoaders = { activity: loadCustomerActivity, chat: initChat } const loadedViews = new Set() function viewFromHash() { const hash = window.location.hash.replace(/^#/, '') return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || '' } function showView(name, { updateHash = true } = {}) { const view = document.getElementById(`view-${name}`) if (!view) return document.querySelectorAll('.kb-view').forEach((v) => v.classList.toggle('active', v === view)) document.querySelectorAll('.kb-nav-item').forEach((b) => b.classList.toggle('active', b.dataset.view === name)) if (!loadedViews.has(name) && viewLoaders[name]) { loadedViews.add(name) viewLoaders[name]() } if (name === 'chat') { api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) } if (updateHash) { const hash = VIEW_HASHES[name] window.history.replaceState(null, '', hash ? `#${hash}` : window.location.pathname) } // App-Ansicht: Titel, Zurueck-Pfeil, Tab und Scrollposition nachziehen (initAppShell) document.dispatchEvent(new CustomEvent('kb:view', { detail: { name } })) } function initialsOf(name) { const words = String(name || '').split(/\s+/).filter((w) => /^[\p{L}\p{N}]/u.test(w)) const letters = words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '').slice(0, 2) return letters.toUpperCase() } async function initDashboardPage() { const list = document.getElementById('projects') const loading = document.getElementById('loading') const empty = document.getElementById('empty') const loadError = document.getElementById('load-error') initAppShell() initPasswordView() // Konto-Menü (Profilbild): Abmelden und "Abo verwalten & Rechnungen" gibt es nur dort document.getElementById('kb-sheet-logout')?.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }) window.location.href = '/login.html' }) document.getElementById('kb-sheet-abo')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, document.getElementById('kb-sheet-error'))) // Seitenleiste, Chips in den Projektkarten und Links im Inhalt schalten die Ansicht um document.addEventListener('click', (ev) => { const target = ev.target.closest('[data-view]') if (!target || target.disabled) return ev.preventDefault() showView(target.dataset.view) }) // Nach dem Login zurueck zur aufgerufenen Ansicht (z. B. #gutschrift aus einer Mail) const loginUrl = window.location.hash ? `/login.html?next=${encodeURIComponent(`/dashboard.html${window.location.hash}`)}` : '/login.html' try { const me = await api('/api/auth/me') if (!me.authenticated) { const target = new URL(loginUrl, window.location.origin) // Passwort auf einem anderen Gerät geändert: Hinweis auf der Anmeldeseite if (me.reason === 'password_changed' && me.message) target.searchParams.set('error', me.message) window.location.href = target.pathname + target.search return } if (me.role === 'admin' && !me.customer) { window.location.href = '/admin.html' return } if (!me.customer) { window.location.href = loginUrl return } const customer = me.customer chatState.viewAs = Boolean(me.viewAs) takeoverState.customer = customer renderViewAsBanner(me) setPasswordAccount(customer, chatState.viewAs) const displayName = customer.companyName || customer.name || customer.email document.getElementById('customer-name').textContent = displayName document.getElementById('customer-meta').textContent = [customer.code ? `Kundennr. ${customer.code}` : '', customer.email].filter(Boolean).join(' · ') document.getElementById('customer-avatar').textContent = initialsOf(displayName) api('/api/contact') .then((c) => { document.getElementById('contact-name').textContent = c.name document.getElementById('contact-response').textContent = c.responseTime }) .catch(() => {}) const aboParam = getQueryParam('abo') if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`) loadAboCard({ afterCheckout: aboParam === 'success' }) loadCustomerInvoices() const initialView = viewFromHash() if (initialView) showView(initialView, { updateHash: false }) const [{ projects }, { features }] = await Promise.all([ api('/api/projects'), api('/api/features'), ]) loading.classList.add('hidden') document.getElementById('projects-count').textContent = projects.length || '' if (projects.length > 1) { document.getElementById('projects-subtitle').textContent = 'Deine Websites – Vorschau, Status und Details.' } if (projects.length) { list.classList.remove('hidden') list.innerHTML = '' for (const project of projects) { list.appendChild(renderProjectCard(project, features)) } } else { empty.classList.remove('hidden') } // Chat-Badge global pollen (auch wenn der Chat nicht offen ist) pollChatBadge() chatState.badgeTimer = setInterval(pollChatBadge, 45000) } catch (err) { loading.classList.add('hidden') if (err.status === 401 || err.message.includes('Nicht angemeldet')) { window.location.href = loginUrl return } showError(loadError, err.message) } } /* ------------------------------------------------------- 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 (und Text neben dem Zurueck-Pfeil). // "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde. const APP_PARENT = { abo: 'wartung', takeover: 'wartung', projekt: 'projects' } const APP_PARENT_LABEL = { wartung: 'Wartung', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' } 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]) const back = document.getElementById('kb-back') back?.classList.toggle('hidden', !sub) if (back && sub) { const label = APP_PARENT_LABEL[APP_PARENT[appShell.view]] || 'Zurück' back.querySelector('span').textContent = label back.setAttribute('aria-label', `Zurück zu ${label}`) } 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 } // Projektdetails als Seite nur in der App-Ansicht und nur mit gewaehltem Projekt (nicht per Direktlink) if (name === 'projekt' && (!isAppLayout() || !projectPage.project)) { showView('projects', { updateHash: !appShell.fromHistory }) return } const prev = appShell.view // "Passwort ändern" (Konto-Menü) ist in der App-Ansicht eine Unterseite der Ansicht, in der man gerade war if (name === 'passwort' && prev !== 'passwort') APP_PARENT.passwort = isAppLayout() ? prev : undefined 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 (Passwort aus einer Unterseite: deren Tab) const tab = APP_PARENT[parent] || parent || name document.querySelectorAll('.kb-nav-item').forEach((b) => { const on = b.dataset.view === tab 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 zum Tab darüber (Wartung bzw. Projekte) if (nav === 'push' && !appShell.fromHistory) { window.history.replaceState(null, '', VIEW_HASHES[parent] ? `#${VIEW_HASHES[parent]}` : window.location.pathname) 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() } /* Chat-Eingabe ueber der Tastatur halten: nur nachschieben, wenn sie wirklich verdeckt ist, gemessen am sichtbaren Bereich (visualViewport). Die Seite nicht nach oben pinnen – das kaempft auf iOS gegen Safaris eigenes Hochschieben und laesst die Eingabe aus dem Bild springen. */ function revealChatComposer() { const composer = document.getElementById('chat-composer') const vv = window.visualViewport if (!composer || !vv || !isAppLayout() || !composer.contains(document.activeElement)) return const rect = composer.getBoundingClientRect() const visibleTop = vv.offsetTop const visibleBottom = vv.offsetTop + vv.height let delta = 0 if (rect.bottom > visibleBottom - 8) delta = rect.bottom - visibleBottom + 8 else if (rect.top < visibleTop + 8) delta = rect.top - visibleTop - 8 if (Number.isFinite(delta) && Math.abs(delta) > 1) window.scrollBy(0, delta) } /* 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, Verträgen, Rechtlichem und Abmelden. Geöffnet über das Profilbild oder Verweise mit data-open-account (z. B. Hinweis unter den Rechnungen). */ function initAccountSheet() { const sheet = document.getElementById('kb-sheet') const avatar = document.getElementById('customer-avatar') if (!sheet || !avatar) return const fill = () => { 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() } if (typeof sheet.showModal !== 'function') { // Sehr alte Browser ohne : das Menü klappt als einfache Box unter der Kopfzeile auf (kb-no-sheet) document.body.classList.add('kb-no-sheet') const show = (on) => { if (on) fill() sheet.toggleAttribute('open', on) } avatar.addEventListener('click', () => show(!sheet.hasAttribute('open'))) document.addEventListener('click', (ev) => { if (ev.target.closest('[data-open-account]')) show(true) else if (ev.target.closest('#kb-sheet [data-sheet-close], #kb-sheet [data-view]')) show(false) }) 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 fill() 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) document.addEventListener('click', (ev) => { if (ev.target.closest('[data-open-account]')) 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') // Fokus zurück aufs Profilbild – außer die geöffnete Ansicht hat ihn schon gesetzt ("Passwort ändern") const active = document.activeElement if (!active || active === document.body || sheet.contains(active)) 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() }) // 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. Auf Android schrumpft die Seite mit der Tastatur // (interactive-widget=resizes-content), dort nutzt der Chat den frei werdenden Platz. Auf iOS liegt // die Tastatur ueber der Seite und Safari schiebt das Feld selbst hoch – dort darf sich beim Fokus // keine Hoehe aendern (kb-ios, siehe kundenbereich.css), sonst rutscht das Feld wieder aus dem Bild. if (isIosDevice()) document.body.classList.add('kb-ios') document.addEventListener('focusin', (ev) => { if (!coarsePointer.matches || !ev.target.matches?.(TEXT_INPUT)) return document.body.classList.add('kb-typing') // Nach der Tastatur-Animation (iOS ca. 350 ms) pruefen, ob die Chat-Eingabe sichtbar ist if (ev.target.closest('#chat-composer')) setTimeout(revealChatComposer, 350) }) document.addEventListener('focusout', () => { setTimeout(() => { if (!document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.remove('kb-typing') }, 80) }) // 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 vv.addEventListener('resize', () => { 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') } if (open) requestAnimationFrame(() => requestAnimationFrame(revealChatComposer)) }) } // Wird der Verlauf kleiner (Tastatur, Tab-Leiste), bleibt die neueste Nachricht sichtbar – sofern // man unten war (data-stick setzt ChatComposer.trackScroll) const messages = document.getElementById('chat-messages') if (messages && typeof ResizeObserver !== 'undefined') { new ResizeObserver(() => { if (messages.dataset.stick !== 'false') 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') if (!isAppLayout() && appShell.view === 'projekt') showView('projects') 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') } // iOS zoomt beim Tippen in ein Feld hinein, wenn dessen Schrift effektiv unter 16 px liegt – z. B. bei // verkleinertem Seitenzoom in Safari, obwohl die Felder 16 px haben. maximum-scale=1 verhindert nur // diesen automatischen Zoom; mit zwei Fingern zoomen bleibt auf iOS moeglich (WebKit ignoriert die // Sperre dafuer seit iOS 10). Nur iOS: Android wuerde damit jedes Zoomen sperren. function preventIosFocusZoom() { const meta = document.querySelector('meta[name="viewport"]') if (meta && isIosDevice() && !/maximum-scale/.test(meta.content)) meta.content += ', maximum-scale=1' } preventIosFocusZoom() // 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